272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
import { useVoiceStore } from '../stores/voice.ts';
|
|
import { useAuthStore, type UserStatus } from '../stores/auth.ts';
|
|
import { useWebSocketStore } from '../stores/ws.ts';
|
|
import { useServerStore } from '../stores/server.ts';
|
|
import { useChannelStore } from '../stores/channel.ts';
|
|
import { useLayoutStore } from '../stores/layout.ts';
|
|
import { useRoleStore } from '../stores/role.ts';
|
|
import { ServerBar } from './ServerBar.tsx';
|
|
import { ChannelList } from './ChannelList.tsx';
|
|
import { ConversationList } from './ConversationList.tsx';
|
|
import { NotificationPrompt } from './NotificationPrompt.tsx';
|
|
import { MemberList } from './MemberList.tsx';
|
|
import { VoicePanel } from './VoicePanel.tsx';
|
|
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
|
import { ThemeToggle } from './ThemeToggle.tsx';
|
|
|
|
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
|
function statusColor(status: UserStatus): string {
|
|
switch (status) {
|
|
case 'online': return 'bg-gb-green';
|
|
case 'idle': return 'bg-gb-yellow';
|
|
case 'dnd': return 'bg-gb-red';
|
|
default: return 'bg-gb-gray';
|
|
}
|
|
}
|
|
function statusLabel(status: UserStatus): string {
|
|
return status.toUpperCase();
|
|
}
|
|
|
|
export function Layout() {
|
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
|
const isLoading = useAuthStore((state) => state.isLoading);
|
|
const user = useAuthStore((state) => state.user);
|
|
const logout = useAuthStore((state) => state.logout);
|
|
const updateProfile = useAuthStore((state) => state.updateProfile);
|
|
const wsConnect = useWebSocketStore((s) => s.connect);
|
|
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
|
const isDM = useLayoutStore((s) => s.isDM);
|
|
const mobileView = useLayoutStore((s) => s.mobileView);
|
|
const setMobileView = useLayoutStore((s) => s.setMobileView);
|
|
const [showServerSettings, setShowServerSettings] = useState(false);
|
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
|
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
|
const fetchMyRoles = useRoleStore((s) => s.fetchMyRoles);
|
|
const currentVoiceRoom = useVoiceStore((s) => s.currentRoom);
|
|
const [activeTab, setActiveTab] = useState<'chat' | 'voice'>('chat');
|
|
|
|
// Load server roles + current user's role assignments for accurate client permission gates.
|
|
useEffect(() => {
|
|
if (!activeServerId || !user?.id) return;
|
|
void fetchRoles(activeServerId);
|
|
void fetchMyRoles(activeServerId, user.id);
|
|
}, [activeServerId, user?.id, fetchRoles, fetchMyRoles]);
|
|
|
|
useEffect(() => {
|
|
if (currentVoiceRoom) setActiveTab('voice');
|
|
else setActiveTab('chat');
|
|
}, [currentVoiceRoom]);
|
|
|
|
useEffect(() => {
|
|
wsConnect();
|
|
return () => { wsDisconnect(); };
|
|
}, [wsConnect, wsDisconnect]);
|
|
|
|
useEffect(() => {
|
|
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
|
navigate('/login', { replace: true });
|
|
}
|
|
}, [isLoading, isAuthenticated, location.pathname, navigate]);
|
|
|
|
// Navigate to chat view on mobile when a channel/DM is selected
|
|
useEffect(() => {
|
|
const unsub = useChannelStore.subscribe((state, prev) => {
|
|
if (state.activeChannelId !== prev.activeChannelId && state.activeChannelId) {
|
|
setMobileView('chat');
|
|
}
|
|
});
|
|
return unsub;
|
|
}, [setMobileView]);
|
|
|
|
useEffect(() => {
|
|
if (location.pathname.startsWith('/dm/') && location.pathname !== '/dm') {
|
|
setMobileView('chat');
|
|
}
|
|
}, [location.pathname, setMobileView]);
|
|
|
|
const handleStatusChange = async (status: UserStatus) => {
|
|
setShowStatusMenu(false);
|
|
try { await updateProfile({ status }); } catch { /* store surfaces error */ }
|
|
};
|
|
const currentStatus = user?.status ?? 'offline';
|
|
|
|
if (isLoading) {
|
|
return <div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">[booting...]</div>;
|
|
}
|
|
if (!isAuthenticated) return null;
|
|
|
|
return (
|
|
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
|
|
{/* Outer terminal frame with safe area */}
|
|
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"
|
|
style={{ padding: 'var(--safe-top, env(safe-area-inset-top, 1.5rem)) var(--safe-right, env(safe-area-inset-right, 0)) var(--safe-bottom, env(safe-area-inset-bottom, 0.75rem)) var(--safe-left, env(safe-area-inset-left, 0))' }}>
|
|
|
|
{/* Top bar — minimal on mobile */}
|
|
<div className="flex items-center justify-between px-2 md:px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s gap-1">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="text-gb-orange font-bold shrink-0 text-sm md:text-base">DUMPSTER</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 md:gap-4 text-xs text-gb-fg-s min-w-0">
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowStatusMenu((prev) => !prev)}
|
|
className="flex items-center gap-1 hover:text-gb-fg transition-colors"
|
|
title="Change status"
|
|
>
|
|
<span className={`w-2 h-2 rounded-full ${statusColor(currentStatus)} shrink-0`} />
|
|
<span className="text-gb-aqua hidden sm:inline truncate max-w-[80px]">{user?.username || 'unknown'}</span>
|
|
<span className="text-gb-fg-f hidden sm:inline">[{statusLabel(currentStatus)}]</span>
|
|
</button>
|
|
{showStatusMenu && (
|
|
<div className="absolute right-0 top-full mt-1 z-50 w-32 bg-gb-bg-s border border-gb-bg-t shadow-lg">
|
|
{STATUS_CYCLE.map((s) => (
|
|
<button key={s} type="button" onClick={() => handleStatusChange(s)}
|
|
className="w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors">
|
|
<span className={`w-2 h-2 rounded-full ${statusColor(s)}`} />
|
|
<span>{statusLabel(s)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="hidden sm:block">
|
|
<ThemeToggle />
|
|
</div>
|
|
<Link to="/settings" className="terminal-button text-xs px-1.5 py-0.5 hidden sm:inline-flex">[SETTINGS]</Link>
|
|
{activeServerId && (
|
|
<button onClick={() => setShowServerSettings(true)}
|
|
className="terminal-button text-xs px-1.5 py-0.5 hidden md:inline-flex">
|
|
[SERVER SETTINGS]
|
|
</button>
|
|
)}
|
|
<button onClick={() => logout().then(() => navigate('/login'))}
|
|
className="terminal-button text-xs px-1.5 py-0.5">
|
|
[LOGOUT]
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content */}
|
|
<div className="flex-1 flex min-h-0 relative">
|
|
{/* Sidebar — desktop: always visible, mobile: overlay when sidebar tab active */}
|
|
<div className={`
|
|
${mobileView === 'sidebar' ? 'flex' : 'hidden'} md:flex
|
|
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto flex-shrink-0
|
|
`}>
|
|
<ServerBar />
|
|
{isDM ? <ConversationList /> : <ChannelList />}
|
|
{/* Close overlay on mobile by tapping background */}
|
|
<div className="flex-1 md:hidden" onClick={() => setMobileView('chat')} />
|
|
</div>
|
|
|
|
{/* Chat */}
|
|
<div className={`
|
|
${mobileView === 'chat' ? 'flex' : 'hidden'} md:flex flex-1 min-w-0 flex-col
|
|
`}>
|
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
|
{currentVoiceRoom && (
|
|
<div className="flex bg-gb-bg-s border-b border-gb-bg-t">
|
|
<button className={`flex-1 py-1.5 text-xs text-center border-b-2 ${activeTab === 'chat' ? 'border-gb-fg-f text-gb-fg-f' : 'border-transparent text-gb-fg-s hover:text-gb-fg-f'}`}
|
|
onClick={() => setActiveTab('chat')}>[CHAT]</button>
|
|
<button className={`flex-1 py-1.5 text-xs text-center border-b-2 ${activeTab === 'voice' ? 'border-gb-fg-f text-gb-fg-f' : 'border-transparent text-gb-fg-s hover:text-gb-fg-f'}`}
|
|
onClick={() => setActiveTab('voice')}>[VOICE/VIDEO]</button>
|
|
</div>
|
|
)}
|
|
<div className={`flex-1 min-h-0 flex-col ${activeTab === 'chat' || !currentVoiceRoom ? 'flex' : 'hidden'}`}>
|
|
<Outlet />
|
|
</div>
|
|
{currentVoiceRoom && (
|
|
<div className={`flex-1 min-h-0 flex-col ${activeTab === 'voice' ? 'flex' : 'hidden'}`}>
|
|
<VoicePanel />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Members — desktop: right column, mobile: overlay when members tab active */}
|
|
{!isDM && (
|
|
<div className={`
|
|
${mobileView === 'members' ? 'flex' : 'hidden'} md:flex
|
|
absolute md:relative inset-0 md:inset-auto z-30 md:z-auto flex-shrink-0
|
|
`}>
|
|
<div className="flex-1 md:hidden" onClick={() => setMobileView('chat')} />
|
|
<MemberList />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom nav — mobile only */}
|
|
<MobileBottomNav
|
|
mobileView={mobileView}
|
|
onViewChange={setMobileView}
|
|
isDM={isDM}
|
|
/>
|
|
|
|
{showServerSettings && activeServerId && (
|
|
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
|
|
)}
|
|
|
|
{/* Status bar — desktop only */}
|
|
<div className="hidden md:flex px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f justify-between bg-gb-bg-s">
|
|
<span>TERM {__APP_VERSION__}</span>
|
|
<span>{new Date().toISOString().slice(0, 10)}</span>
|
|
</div>
|
|
</div>
|
|
<NotificationPrompt />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// MobileBottomNav — tab bar for switching between sidebar / chat / members
|
|
function MobileBottomNav({
|
|
mobileView,
|
|
onViewChange,
|
|
isDM,
|
|
}: {
|
|
mobileView: string;
|
|
onViewChange: (v: 'sidebar' | 'chat' | 'members') => void;
|
|
isDM: boolean;
|
|
}) {
|
|
const isConnected = useVoiceStore((s) => s.isConnected);
|
|
|
|
return (
|
|
<div className="md:hidden flex items-center bg-gb-bg-h border-t border-gb-bg-t shrink-0"
|
|
style={{ paddingBottom: 'var(--safe-bottom, env(safe-area-inset-bottom))' }}>
|
|
<NavTab active={mobileView === 'sidebar'} onClick={() => onViewChange('sidebar')}>
|
|
[SERVERS]
|
|
</NavTab>
|
|
<NavTab active={mobileView === 'chat'} onClick={() => onViewChange('chat')}>
|
|
[CHAT]
|
|
</NavTab>
|
|
{!isDM && (
|
|
<NavTab active={mobileView === 'members'} onClick={() => onViewChange('members')}>
|
|
[MEMBERS]
|
|
</NavTab>
|
|
)}
|
|
{isConnected && (
|
|
<div className="w-2 h-2 rounded-full bg-gb-green animate-pulse ml-auto mr-3" title="In voice" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NavTab({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`flex-1 py-2.5 text-xs font-mono text-center transition-colors border-t-2 ${
|
|
active ? 'border-gb-orange text-gb-orange bg-gb-bg-s' : 'border-transparent text-gb-fg-f hover:text-gb-fg hover:bg-gb-bg'
|
|
}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|