56af584ede
Moved thread and forum-tag routes from nested /servers/{sid}/channels/ to
top-level /channels/{id}/... to match frontend API calls. Forum posts were
getting SPA HTML fallback instead of JSON.
Added orange notification dots to DM and server buttons in ServerBar.
178 lines
7.4 KiB
TypeScript
178 lines
7.4 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useServerStore } from "../stores/server.ts";
|
|
import { useChannelStore } from "../stores/channel.ts";
|
|
import { useLayoutStore } from "../stores/layout.ts";
|
|
import { useConversationStore } from "../stores/conversation.ts";
|
|
import { useReadStatesStore } from "../stores/readStates.ts";
|
|
import { CreateServerModal } from "./CreateServerModal.tsx";
|
|
import { JoinServerModal } from "./JoinServerModal.tsx";
|
|
import { ServerSettingsModal } from "./ServerSettingsModal.tsx";
|
|
import { InviteModal } from "./InviteModal.tsx";
|
|
import { useContextMenu } from "./ContextMenu.tsx";
|
|
import { api } from "../lib/api.ts";
|
|
|
|
function getInitials(name: string): string {
|
|
const words = name.trim().split(/\s+/);
|
|
if (words.length === 1) {
|
|
return words[0].slice(0, 2).toUpperCase();
|
|
}
|
|
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
|
}
|
|
|
|
export function ServerBar() {
|
|
const servers = useServerStore((state) => state.servers);
|
|
const activeServerId = useServerStore((state) => state.activeServerId);
|
|
const fetchServers = useServerStore((state) => state.fetchServers);
|
|
const setActiveServer = useServerStore((state) => state.setActiveServer);
|
|
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
|
const { isDM, setDM } = useLayoutStore();
|
|
const navigate = useNavigate();
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [showJoin, setShowJoin] = useState(false);
|
|
const [settingsServerId, setSettingsServerId] = useState<string | null>(null);
|
|
const [inviteServer, setInviteServer] = useState<{ id: string; name: string } | null>(null);
|
|
|
|
const { showMenu, MenuPortal } = useContextMenu();
|
|
|
|
// ponytail: compute if any DM conversation has unread messages
|
|
const conversations = useConversationStore((s) => s.conversations);
|
|
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
|
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
|
|
const hasAnyDMUnread = useMemo(() => {
|
|
return conversations.some((conv) => {
|
|
const msgs = messagesByConv[conv.id] || [];
|
|
const latestId = msgs.length > 0 ? msgs[msgs.length - 1].id : undefined;
|
|
return hasConvUnread(conv.id, latestId);
|
|
});
|
|
}, [conversations, messagesByConv, hasConvUnread]);
|
|
|
|
useEffect(() => {
|
|
fetchServers().then(() => {
|
|
// ponytail: restore last active server + channel from localStorage
|
|
try {
|
|
const raw = localStorage.getItem('dumpster:lastChannel');
|
|
if (raw) {
|
|
const { serverId, channelId } = JSON.parse(raw);
|
|
const srv = useServerStore.getState().servers.find((s) => s.id === serverId);
|
|
if (srv) {
|
|
setActiveServer(serverId);
|
|
setDM(false);
|
|
// Fetch channels then restore the channel
|
|
useChannelStore.getState().fetchChannels(serverId).then(() => {
|
|
const ch = useChannelStore.getState().channelsByServer[serverId]?.find((c) => c.id === channelId);
|
|
if (ch) {
|
|
setActiveChannel(channelId);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch { /* ignore bad localStorage */ }
|
|
});
|
|
}, [fetchServers, setActiveServer, setActiveChannel, setDM]);
|
|
|
|
const handleSelect = (id: string) => {
|
|
setActiveServer(id);
|
|
setActiveChannel(null);
|
|
setDM(false);
|
|
navigate('/');
|
|
};
|
|
|
|
const handleDM = () => {
|
|
setActiveServer(null);
|
|
setActiveChannel(null);
|
|
setDM(true);
|
|
navigate('/dm');
|
|
};
|
|
|
|
const handleServerContextMenu = (e: React.MouseEvent, server: { id: string; name: string }) => {
|
|
showMenu(e, [
|
|
{ label: 'Server Settings', icon: '⚙', onClick: () => setSettingsServerId(server.id) },
|
|
{ label: 'Invite People', icon: '📨', onClick: () => setInviteServer({ id: server.id, name: server.name }) },
|
|
{ label: 'Leave Server', icon: '🚪', danger: true, onClick: () => leaveServer(server.id, server.name) },
|
|
]);
|
|
};
|
|
|
|
const leaveServer = async (serverId: string, serverName: string) => {
|
|
if (!confirm(`Leave "${serverName}"? You'll need an invite to rejoin.`)) return;
|
|
try {
|
|
await api.delete(`/servers/${serverId}/members/me`);
|
|
fetchServers();
|
|
if (activeServerId === serverId) {
|
|
setActiveServer(null);
|
|
setDM(true);
|
|
}
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="h-full w-16 bg-gb-bg-h border-r border-gb-bg-t flex flex-col items-center py-3 gap-2 overflow-y-auto">
|
|
<div className="relative">
|
|
<button
|
|
onClick={handleDM}
|
|
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
|
(isDM
|
|
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
|
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua')}
|
|
title="Direct messages"
|
|
>
|
|
[@]
|
|
</button>
|
|
{hasAnyDMUnread && !isDM && (
|
|
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-gb-orange rounded-full border-2 border-gb-bg-h" />
|
|
)}
|
|
</div>
|
|
<div className="w-8 h-px bg-gb-bg-t" />
|
|
{servers.map((server) => (
|
|
<div key={server.id} className="relative">
|
|
<button
|
|
onClick={() => handleSelect(server.id)}
|
|
onContextMenu={(e) => handleServerContextMenu(e, server)}
|
|
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
|
(server.id === activeServerId && !isDM
|
|
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
|
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua')}
|
|
title={server.name}
|
|
>
|
|
{server.unread && server.id !== activeServerId ? (
|
|
<span className="text-gb-aqua">[{getInitials(server.name)}]</span>
|
|
) : (
|
|
'[' + getInitials(server.name) + ']'
|
|
)}
|
|
</button>
|
|
{server.unread && server.id !== activeServerId && (
|
|
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-gb-orange rounded-full border-2 border-gb-bg-h" />
|
|
)}
|
|
</div>
|
|
))}
|
|
<div className="mt-2 flex flex-col gap-2 items-center">
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="w-11 h-11 flex items-center justify-center font-mono text-sm border bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua"
|
|
title="Create server"
|
|
>
|
|
[+]
|
|
</button>
|
|
<button
|
|
onClick={() => setShowJoin(true)}
|
|
className="w-11 h-11 flex items-center justify-center font-mono text-xs border bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua"
|
|
title="Join server"
|
|
>
|
|
[#]
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{showCreate && <CreateServerModal onClose={() => setShowCreate(false)} />}
|
|
{showJoin && <JoinServerModal onClose={() => setShowJoin(false)} />}
|
|
{settingsServerId && (
|
|
<ServerSettingsModal serverId={settingsServerId} onClose={() => setSettingsServerId(null)} />
|
|
)}
|
|
{inviteServer && (
|
|
<InviteModal serverId={inviteServer.id} serverName={inviteServer.name} onClose={() => setInviteServer(null)} />
|
|
)}
|
|
{MenuPortal}
|
|
</>
|
|
);
|
|
}
|