240 lines
8.8 KiB
TypeScript
240 lines
8.8 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { signOut, useSession } from '@/lib/auth-client';
|
|
|
|
interface AppHeaderProps {
|
|
conversationTitle?: string | null;
|
|
isSidebarOpen: boolean;
|
|
onToggleSidebar: () => void;
|
|
}
|
|
|
|
type ThemeMode = 'dark' | 'light';
|
|
|
|
const THEME_STORAGE_KEY = 'mosaic-chat-theme';
|
|
|
|
export function AppHeader({
|
|
conversationTitle,
|
|
isSidebarOpen,
|
|
onToggleSidebar,
|
|
}: AppHeaderProps): React.ReactElement {
|
|
const { data: session } = useSession();
|
|
const [currentTime, setCurrentTime] = useState('');
|
|
const [version, setVersion] = useState<string | null>(null);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [theme, setTheme] = useState<ThemeMode>('dark');
|
|
|
|
useEffect(() => {
|
|
function updateTime(): void {
|
|
setCurrentTime(
|
|
new Date().toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}),
|
|
);
|
|
}
|
|
|
|
updateTime();
|
|
const interval = window.setInterval(updateTime, 60_000);
|
|
return () => window.clearInterval(interval);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetch('/version.json')
|
|
.then(async (res) => res.json() as Promise<{ version?: string; commit?: string }>)
|
|
.then((data) => {
|
|
if (data.version) {
|
|
setVersion(data.commit ? `${data.version}+${data.commit}` : data.version);
|
|
}
|
|
})
|
|
.catch(() => setVersion(null));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
const nextTheme = storedTheme === 'light' ? 'light' : 'dark';
|
|
applyTheme(nextTheme);
|
|
setTheme(nextTheme);
|
|
}, []);
|
|
|
|
const handleThemeToggle = useCallback(() => {
|
|
const nextTheme = theme === 'dark' ? 'light' : 'dark';
|
|
applyTheme(nextTheme);
|
|
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
|
setTheme(nextTheme);
|
|
}, [theme]);
|
|
|
|
const handleSignOut = useCallback(async (): Promise<void> => {
|
|
await signOut();
|
|
window.location.href = '/login';
|
|
}, []);
|
|
|
|
const userLabel = session?.user.name ?? session?.user.email ?? 'Mosaic User';
|
|
const initials = useMemo(() => getInitials(userLabel), [userLabel]);
|
|
|
|
return (
|
|
<header
|
|
className="sticky top-0 z-20 border-b backdrop-blur-xl"
|
|
style={{
|
|
backgroundColor: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between gap-3 px-4 py-3 md:px-6">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onToggleSidebar}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border transition-colors hover:bg-white/5"
|
|
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
|
aria-label="Toggle conversation sidebar"
|
|
aria-expanded={isSidebarOpen}
|
|
>
|
|
☰
|
|
</button>
|
|
|
|
<Link href="/chat" className="flex min-w-0 items-center gap-3">
|
|
<div
|
|
className="flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[var(--shadow-ms-md)]"
|
|
style={{
|
|
background:
|
|
'linear-gradient(135deg, var(--color-ms-blue-500), var(--color-ms-teal-500))',
|
|
}}
|
|
>
|
|
M
|
|
</div>
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<div className="text-sm font-semibold text-[var(--color-text)]">Mosaic</div>
|
|
<div className="hidden h-5 w-px bg-[var(--color-border)] md:block" />
|
|
<div className="hidden items-center gap-2 md:flex">
|
|
<span className="relative flex h-2.5 w-2.5">
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-ms-teal-500)] opacity-60" />
|
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-ms-teal-500)]" />
|
|
</span>
|
|
<span className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
|
Online
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="hidden min-w-0 items-center gap-3 md:flex">
|
|
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-2)]">
|
|
{currentTime || '--:--'}
|
|
</div>
|
|
<div className="max-w-[24rem] truncate text-sm font-medium text-[var(--color-text)]">
|
|
{conversationTitle?.trim() || 'New Session'}
|
|
</div>
|
|
{version ? (
|
|
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
|
v{version}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="hidden items-center gap-2 lg:flex">
|
|
<ShortcutHint label="⌘/" text="focus" />
|
|
<ShortcutHint label="⌘K" text="focus" />
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleThemeToggle}
|
|
className="inline-flex h-10 items-center justify-center rounded-2xl border px-3 text-sm transition-colors hover:bg-white/5"
|
|
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
|
aria-label="Toggle theme"
|
|
>
|
|
{theme === 'dark' ? '☀︎' : '☾'}
|
|
</button>
|
|
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMenuOpen((prev) => !prev)}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-full border text-sm font-semibold transition-colors hover:bg-white/5"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface-2)',
|
|
borderColor: 'var(--color-border)',
|
|
color: 'var(--color-text)',
|
|
}}
|
|
aria-expanded={menuOpen}
|
|
aria-label="Open user menu"
|
|
>
|
|
{session?.user.image ? (
|
|
<img
|
|
src={session.user.image}
|
|
alt={userLabel}
|
|
className="h-full w-full rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
initials
|
|
)}
|
|
</button>
|
|
{menuOpen ? (
|
|
<div
|
|
className="absolute right-0 top-12 min-w-56 rounded-3xl border p-2 shadow-[var(--shadow-ms-lg)]"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface)',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
>
|
|
<div className="border-b px-3 py-2" style={{ borderColor: 'var(--color-border)' }}>
|
|
<div className="text-sm font-medium text-[var(--color-text)]">{userLabel}</div>
|
|
{session?.user.email ? (
|
|
<div className="text-xs text-[var(--color-muted)]">{session.user.email}</div>
|
|
) : null}
|
|
</div>
|
|
<div className="p-1">
|
|
<Link
|
|
href="/settings"
|
|
className="flex rounded-2xl px-3 py-2 text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
|
onClick={() => setMenuOpen(false)}
|
|
>
|
|
Settings
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleSignOut()}
|
|
className="flex w-full rounded-2xl px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
|
>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function ShortcutHint({ label, text }: { label: string; text: string }): React.ReactElement {
|
|
return (
|
|
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
|
<span className="font-medium text-[var(--color-text-2)]">{label}</span>
|
|
<span>{text}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function getInitials(label: string): string {
|
|
const words = label.split(/\s+/).filter(Boolean).slice(0, 2);
|
|
if (words.length === 0) return 'M';
|
|
return words.map((word) => word.charAt(0).toUpperCase()).join('');
|
|
}
|
|
|
|
function applyTheme(theme: ThemeMode): void {
|
|
const root = document.documentElement;
|
|
if (theme === 'light') {
|
|
root.setAttribute('data-theme', 'light');
|
|
root.classList.remove('dark');
|
|
} else {
|
|
root.removeAttribute('data-theme');
|
|
root.classList.add('dark');
|
|
}
|
|
}
|