refactor(settings): sync status and color updates directly via UserContext
Some checks failed
Deploy Waveform to VPS / deploy (push) Failing after 14s

This commit is contained in:
Chneemann 2026-09-23 10:32:44 +02:00
parent 00b11f2113
commit 256c3d03e3
No known key found for this signature in database
6 changed files with 78 additions and 108 deletions

View file

@ -76,7 +76,6 @@ export default async function AppLayout({
<AppSidebar <AppSidebar
servers={userServers} servers={userServers}
conversations={formattedConversations} conversations={formattedConversations}
user={currentUser}
/> />
<div className="flex-1 flex min-w-0">{children}</div> <div className="flex-1 flex min-w-0">{children}</div>
<MemberSidebar /> <MemberSidebar />

View file

@ -13,7 +13,6 @@ import {
useActiveServer, useActiveServer,
type ServerWithChannels, type ServerWithChannels,
} from "@/lib/context/ServerContext"; } from "@/lib/context/ServerContext";
import type { UserStatus } from "@/db/schema";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { import {
DirectMessageSidebar, DirectMessageSidebar,
@ -21,26 +20,14 @@ import {
} from "../sidebar/DirectMessageSidebar"; } from "../sidebar/DirectMessageSidebar";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
/** Properties representing the user in the sidebar. */
export interface SidebarUser {
username: string;
color: string;
status: UserStatus;
}
/** Properties for the AppSidebar component. */ /** Properties for the AppSidebar component. */
interface AppSidebarProps { interface AppSidebarProps {
servers: ServerWithChannels[]; servers: ServerWithChannels[];
conversations: SidebarConversation[]; conversations: SidebarConversation[];
user: SidebarUser;
} }
/**Renders the responsive application sidebar containing server navigation, channel lists, and user profile. */ /** Renders the responsive application sidebar containing server navigation, channel lists, and user profile. */
export function AppSidebar({ export function AppSidebar({ servers, conversations = [] }: AppSidebarProps) {
servers,
user,
conversations = [],
}: AppSidebarProps) {
const { isNavOpen, closeAll } = useSidebarStore(); const { isNavOpen, closeAll } = useSidebarStore();
const { activeServer } = useActiveServer(); const { activeServer } = useActiveServer();
const pathname = usePathname(); const pathname = usePathname();
@ -66,7 +53,7 @@ export function AppSidebar({
)} )}
</div> </div>
<div className="w-78"> <div className="w-78">
<UserPanel user={user} /> <UserPanel />
</div> </div>
</aside> </aside>
@ -92,7 +79,7 @@ export function AppSidebar({
<ChannelSidebar /> <ChannelSidebar />
)} )}
</div> </div>
<UserPanel user={user} /> <UserPanel />
</div> </div>
</> </>
); );

View file

@ -1,33 +1,22 @@
/** /**
* @file components/settings/SettingsView.tsx * @file components/settings/SettingsView.tsx
* @description Main settings container component for managing user profile and preferences. * @description Settings view component for updating user profile attributes like online status and avatar color.
*/ */
"use client"; "use client";
import { useState } from "react";
import { UserAvatar } from "@/components/ui/UserAvatar"; import { UserAvatar } from "@/components/ui/UserAvatar";
import { UserStatus, User } from "@/db/schema"; import { UserStatus } from "@/db/schema";
import { import {
MEMBER_STATUS_COLOR_CLASSES, MEMBER_STATUS_COLOR_CLASSES,
MEMBER_COLOR_OPTIONS, MEMBER_COLOR_OPTIONS,
} from "@/lib/constants/member.styles"; } from "@/lib/constants/member.styles";
import { UserRound, Palette } from "lucide-react"; import { UserRound, Palette } from "lucide-react";
import { useUser } from "@/lib/context/UserContext";
/** Props for the SettingsView component. */ /** Renders the user settings view allowing profile, online status, and color customizations. */
interface SettingsViewProps { export function SettingsView() {
userId: string; const { currentUser, updateCurrentUser } = useUser();
currentUser: User;
}
/** Renders the user settings view for profile management, status selection, and avatar color customization. */
export function SettingsView({ userId, currentUser }: SettingsViewProps) {
const [selectedStatus, setSelectedStatus] = useState<UserStatus>(
currentUser.status,
);
const [selectedColor, setSelectedColor] = useState<string>(
currentUser.color || MEMBER_COLOR_OPTIONS[0],
);
const statuses: { label: string; value: UserStatus }[] = [ const statuses: { label: string; value: UserStatus }[] = [
{ label: "Online", value: "ONLINE" }, { label: "Online", value: "ONLINE" },
@ -36,42 +25,18 @@ export function SettingsView({ userId, currentUser }: SettingsViewProps) {
{ label: "DND", value: "DND" }, { label: "DND", value: "DND" },
]; ];
/** Updates local status state on user selection. */ /** Sends optimistic updates to context and persists profile changes to the server API. */
const handleStatusChange = async (newStatus: UserStatus) => { const updateProfile = (fields: Partial<typeof currentUser>) => {
setSelectedStatus(newStatus); updateCurrentUser(fields);
try { fetch("/api/users/profile", {
await fetch("/api/users/profile", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }), body: JSON.stringify(fields),
}); }).catch((err) => console.error("Failed to update profile", err));
} catch (err) {
console.error("Failed to update status", err);
}
};
/** Updates local avatar color state on user selection. */
const handleColorChange = async (newColor: string) => {
setSelectedColor(newColor);
try {
await fetch("/api/users/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ color: newColor }),
});
} catch (err) {
console.error("Failed to update color", err);
}
}; };
if (!currentUser) return null; if (!currentUser) return null;
const userWithUpdatedSettings = {
...currentUser,
status: selectedStatus,
color: selectedColor,
};
return ( return (
<div className="flex flex-col h-full w-full bg-background text-white gap-6"> <div className="flex flex-col h-full w-full bg-background text-white gap-6">
{/* Header */} {/* Header */}
@ -85,7 +50,7 @@ export function SettingsView({ userId, currentUser }: SettingsViewProps) {
{/* Profile Overview Card */} {/* Profile Overview Card */}
<div className="bg-surface border border-muted/20 rounded-xl p-6 space-y-6"> <div className="bg-surface border border-muted/20 rounded-xl p-6 space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<UserAvatar user={userWithUpdatedSettings} size="md" /> <UserAvatar user={currentUser} size="md" />
<div> <div>
<h2 className="text-lg font-bold text-white"> <h2 className="text-lg font-bold text-white">
{currentUser.username} {currentUser.username}
@ -101,14 +66,12 @@ export function SettingsView({ userId, currentUser }: SettingsViewProps) {
</h3> </h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{statuses.map((status) => { {statuses.map((status) => {
const isSelected = selectedStatus === status.value; const isSelected = currentUser.status === status.value;
const statusBgColor = MEMBER_STATUS_COLOR_CLASSES[status.value];
return ( return (
<button <button
key={status.value} key={status.value}
type="button" type="button"
onClick={() => handleStatusChange(status.value)} onClick={() => updateProfile({ status: status.value })}
className={`flex items-center gap-2.5 p-3 rounded-lg border text-sm font-medium transition-all ${ className={`flex items-center gap-2.5 p-3 rounded-lg border text-sm font-medium transition-all ${
isSelected isSelected
? "border-accent bg-accent/10 text-white font-semibold" ? "border-accent bg-accent/10 text-white font-semibold"
@ -116,7 +79,9 @@ export function SettingsView({ userId, currentUser }: SettingsViewProps) {
}`} }`}
> >
<span <span
className={`w-2.5 h-2.5 rounded-full ${statusBgColor}`} className={`w-2.5 h-2.5 rounded-full ${
MEMBER_STATUS_COLOR_CLASSES[status.value]
}`}
/> />
{status.label} {status.label}
</button> </button>
@ -132,12 +97,12 @@ export function SettingsView({ userId, currentUser }: SettingsViewProps) {
</h3> </h3>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{MEMBER_COLOR_OPTIONS.map((colorClass) => { {MEMBER_COLOR_OPTIONS.map((colorClass) => {
const isSelected = selectedColor === colorClass; const isSelected = currentUser.color === colorClass;
return ( return (
<button <button
key={colorClass} key={colorClass}
type="button" type="button"
onClick={() => handleColorChange(colorClass)} onClick={() => updateProfile({ color: colorClass })}
className={`w-8 h-8 rounded-full ${colorClass} transition-all flex items-center justify-center ${ className={`w-8 h-8 rounded-full ${colorClass} transition-all flex items-center justify-center ${
isSelected isSelected
? "ring-2 ring-white ring-offset-2 ring-offset-surface scale-110" ? "ring-2 ring-white ring-offset-2 ring-offset-surface scale-110"

View file

@ -1,6 +1,6 @@
/** /**
* @file components/sidebar/UserPanel.tsx * @file components/layout/UserPanel.tsx
* @description Client component providing a footer user panel with session information, avatar display, online status, and settings/logout actions. * @description User panel component displaying the current user's avatar, status, settings link, and logout button.
*/ */
"use client"; "use client";
@ -8,31 +8,14 @@
import { LogOut, Settings } from "lucide-react"; import { LogOut, Settings } from "lucide-react";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { UserAvatar } from "@/components/ui/UserAvatar"; import { UserAvatar } from "@/components/ui/UserAvatar";
import { UserStatus } from "@/db/schema"; import { useUser } from "@/lib/context/UserContext";
import Link from "next/link"; import Link from "next/link";
/** /** Renders the bottom user panel with user information and quick action controls. */
* Props for the UserPanel component. export function UserPanel() {
*/ const { currentUser } = useUser();
interface UserPanelProps {
/** User details including username, avatar color, and online status. */
user: {
username: string;
color: string;
status: UserStatus;
};
}
/** /** Handles logging out the user and redirecting to the login page. */
* Renders the user panel footer containing the user's avatar, status, and action buttons.
*
* @param {UserPanelProps} props - Component properties.
* @returns {JSX.Element} The rendered user panel component.
*/
export function UserPanel({ user }: UserPanelProps) {
/**
* Handles user logout by invalidating the local session and redirecting to the login page.
*/
const handleLogout = async () => { const handleLogout = async () => {
await fetch("/api/auth/logout", { method: "POST" }); await fetch("/api/auth/logout", { method: "POST" });
await signOut({ callbackUrl: "/login" }); await signOut({ callbackUrl: "/login" });
@ -43,17 +26,17 @@ export function UserPanel({ user }: UserPanelProps) {
<footer className="h-14 bg-background/80 hover:bg-background/90 border border-background/50 rounded-xl flex items-center justify-between px-3 gap-2 shadow-lg backdrop-blur-md transition-all duration-200"> <footer className="h-14 bg-background/80 hover:bg-background/90 border border-background/50 rounded-xl flex items-center justify-between px-3 gap-2 shadow-lg backdrop-blur-md transition-all duration-200">
<div className="flex items-center gap-2.5 min-w-0 flex-1 cursor-pointer group"> <div className="flex items-center gap-2.5 min-w-0 flex-1 cursor-pointer group">
<UserAvatar <UserAvatar
user={user} user={currentUser}
size="sm" size="sm"
className="transition-transform group-hover:scale-105" className="transition-transform group-hover:scale-105"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate leading-tight group-hover:text-accent transition-colors"> <p className="text-sm font-semibold text-foreground truncate leading-tight group-hover:text-accent transition-colors">
{user.username} {currentUser.username}
</p> </p>
<p className="text-xs text-muted truncate leading-tight font-medium capitalize"> <p className="text-xs text-muted truncate leading-tight font-medium capitalize">
{user.status.toLowerCase()} {currentUser.status.toLowerCase()}
</p> </p>
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
/** /**
* @file lib/context/ServerContext.tsx * @file lib/context/ServerContext.tsx
* @description Context for managing active server state and members across sidebars and mobile drawers. * @description Context for managing active server state, current user state, and members across sidebars and mobile drawers.
*/ */
"use client"; "use client";
@ -31,6 +31,9 @@ export interface ServerMember {
interface ServerContextType { interface ServerContextType {
activeServer: ServerWithChannels | null; activeServer: ServerWithChannels | null;
setActiveServer: (server: ServerWithChannels | null) => void; setActiveServer: (server: ServerWithChannels | null) => void;
currentUser: User | null;
setCurrentUser: (user: User | null) => void;
updateCurrentUser: (partialUser: Partial<User>) => void;
addChannel: (channel: Channel) => void; addChannel: (channel: Channel) => void;
removeChannel: (channelId: string) => void; removeChannel: (channelId: string) => void;
updateChannel: (channel: Channel) => void; updateChannel: (channel: Channel) => void;
@ -43,11 +46,18 @@ interface ServerContextType {
const ServerContext = createContext<ServerContextType | null>(null); const ServerContext = createContext<ServerContextType | null>(null);
/** Provides active server, channel, and member management state to child components. */ /** Provides active server, channel, user, and member management state to child components. */
export function ServerProvider({ children }: { children: React.ReactNode }) { export function ServerProvider({
children,
initialUser = null,
}: {
children: React.ReactNode;
initialUser?: User | null;
}) {
const [activeServer, setActiveServer] = useState<ServerWithChannels | null>( const [activeServer, setActiveServer] = useState<ServerWithChannels | null>(
null, null,
); );
const [currentUser, setCurrentUser] = useState<User | null>(initialUser);
const [members, setMembers] = useState<ServerMember[]>([]); const [members, setMembers] = useState<ServerMember[]>([]);
/** Updates the active server state using a partial updater function. */ /** Updates the active server state using a partial updater function. */
@ -57,6 +67,19 @@ export function ServerProvider({ children }: { children: React.ReactNode }) {
setActiveServer((prev) => (prev ? { ...prev, ...fn(prev) } : null)); setActiveServer((prev) => (prev ? { ...prev, ...fn(prev) } : null));
}; };
/** Updates the local current user state and reflects changes in active server members list. */
const updateCurrentUser = (partialUser: Partial<User>) => {
setCurrentUser((prev) => (prev ? { ...prev, ...partialUser } : null));
if (currentUser?.id) {
updateServer((s) => ({
members: s.members.map((m) =>
m.id === currentUser.id ? { ...m, ...partialUser } : m,
),
}));
}
};
/** Adds a new channel to the active server. */ /** Adds a new channel to the active server. */
const addChannel = (ch: Channel) => const addChannel = (ch: Channel) =>
updateServer((s) => ({ channels: [...s.channels, ch] })); updateServer((s) => ({ channels: [...s.channels, ch] }));
@ -95,6 +118,9 @@ export function ServerProvider({ children }: { children: React.ReactNode }) {
value={{ value={{
activeServer, activeServer,
setActiveServer, setActiveServer,
currentUser,
setCurrentUser,
updateCurrentUser,
addChannel, addChannel,
removeChannel, removeChannel,
updateChannel, updateChannel,

View file

@ -5,7 +5,7 @@
"use client"; "use client";
import { createContext, useContext } from "react"; import { createContext, useContext, useState } from "react";
import type { User } from "@/db/schema"; import type { User } from "@/db/schema";
/** Represents a friendship record between two users. */ /** Represents a friendship record between two users. */
@ -19,6 +19,7 @@ interface Friendship {
interface UserContextType { interface UserContextType {
currentUser: User; currentUser: User;
friendships: Friendship[]; friendships: Friendship[];
updateCurrentUser: (partialUser: Partial<User>) => void;
} }
const UserContext = createContext<UserContextType | null>(null); const UserContext = createContext<UserContextType | null>(null);
@ -26,15 +27,24 @@ const UserContext = createContext<UserContextType | null>(null);
/** Provides user data and friendship states to child components. */ /** Provides user data and friendship states to child components. */
export function UserProvider({ export function UserProvider({
children, children,
currentUser, currentUser: initialUser,
friendships, friendships,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
currentUser: User; currentUser: User;
friendships: Friendship[]; friendships: Friendship[];
}) { }) {
const [currentUser, setCurrentUser] = useState<User>(initialUser);
/** Updates local current user state dynamically. */
const updateCurrentUser = (partialUser: Partial<User>) => {
setCurrentUser((prev) => ({ ...prev, ...partialUser }));
};
return ( return (
<UserContext.Provider value={{ currentUser, friendships }}> <UserContext.Provider
value={{ currentUser, friendships, updateCurrentUser }}
>
{children} {children}
</UserContext.Provider> </UserContext.Provider>
); );