sync: phase 1 backend + frontend from server

This commit is contained in:
2026-06-30 09:26:03 -04:00
parent d4cdd89544
commit 30e159cbdb
31 changed files with 4758 additions and 114 deletions
+121 -21
View File
@@ -5,6 +5,9 @@ import { useServerStore } from "../stores/server.ts";
import { useMemberStore } from "../stores/member.ts";
import { GiphyPicker, type Gif } from "./GiphyPicker";
import { MentionDropdown } from "./MentionDropdown";
import { MessageSearch } from "./MessageSearch";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
function formatTime(iso: string): string {
const date = new Date(iso);
@@ -15,39 +18,85 @@ function formatTime(iso: string): string {
function renderContent(content: string, memberUsernames: Set<string>) {
// Split on @username, preserving mentions.
const parts: (string | { mention: string })[] = [];
// Split on @username, preserving mentions; render plain text segments as markdown.
const segments: { type: "text" | "mention"; value: string }[] = [];
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
let last = 0;
let match: RegExpExecArray | null;
while ((match = mentionRe.exec(content)) !== null) {
if (match.index > last) {
parts.push(content.slice(last, match.index));
segments.push({ type: "text", value: content.slice(last, match.index) });
}
const username = match[1];
if (memberUsernames.has(username)) {
parts.push({ mention: username });
segments.push({ type: "mention", value: username });
} else {
parts.push(match[0]);
segments.push({ type: "text", value: match[0] });
}
last = mentionRe.lastIndex;
}
if (last < content.length) {
parts.push(content.slice(last));
segments.push({ type: "text", value: content.slice(last) });
}
return parts.map((part, idx) => {
if (typeof part === "string") {
return <span key={idx}>{part}</span>;
return segments.map((seg, idx) => {
if (seg.type === "mention") {
return (
<span key={idx} className="text-gb-aqua">
@{seg.value}
</span>
);
}
return (
<span key={idx} className="text-gb-aqua">
@{part.mention}
</span>
<ReactMarkdown
key={idx}
remarkPlugins={[remarkGfm]}
components={{
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
table: ({ ...props }) => <table {...props} className="border-collapse border border-gb-bg-t my-1" />,
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />,
td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />,
p: ({ ...props }) => <span {...props} className="inline" />,
}}
>
{seg.value}
</ReactMarkdown>
);
});
}
function renderEmbeds(embeds?: { url: string; title?: string; description?: string; image_url?: string; site_name?: string }[]) {
if (!embeds || embeds.length === 0) return null;
return (
<div className="mt-1 space-y-1">
{embeds.map((embed) => (
<a
key={embed.url}
href={embed.url}
target="_blank"
rel="noreferrer"
className="block bg-gb-bg-s border border-gb-bg-t p-2 hover:border-gb-fg-t transition-colors"
>
{embed.site_name && <div className="text-gb-fg-f text-[10px] uppercase">{embed.site_name}</div>}
{embed.title && <div className="text-gb-fg text-xs font-bold truncate">{embed.title}</div>}
{embed.description && <div className="text-gb-fg-s text-xs line-clamp-2">{embed.description}</div>}
{embed.image_url && (
<img
src={embed.image_url}
alt=""
className="mt-1 max-h-24 object-cover border border-gb-bg-t"
loading="lazy"
/>
)}
</a>
))}
</div>
);
}
export function ChatArea() {
const activeChannelId = useChannelStore((s) => s.activeChannelId);
const channelsByServer = useChannelStore((s) => s.channelsByServer);
@@ -62,7 +111,9 @@ export function ChatArea() {
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [showGifPicker, setShowGifPicker] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -87,6 +138,20 @@ export function ChatArea() {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
useEffect(() => {
if (slowmodeRemaining <= 0) return;
const t = setInterval(() => {
setSlowmodeRemaining((r) => {
if (r <= 1) {
clearInterval(t);
return 0;
}
return r - 1;
});
}, 1000);
return () => clearInterval(t);
}, [slowmodeRemaining]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length;
@@ -132,14 +197,25 @@ export function ChatArea() {
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!activeChannelId || !input.trim()) return;
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
setError(null);
try {
await sendMessage(activeChannelId, input.trim());
setInput("");
setMentionQuery(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send");
const msg = err instanceof Error ? err.message : "Failed to send";
// Parse slowmode error from API.
try {
const parsed = JSON.parse(msg);
if (parsed.error === "slowmode" && typeof parsed.retry_after === "number") {
setSlowmodeRemaining(parsed.retry_after);
return;
}
} catch {
// not json
}
setError(msg);
}
};
@@ -156,13 +232,31 @@ export function ChatArea() {
return (
<div className="flex flex-col h-full bg-gb-bg">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
{activeChannel
? `# ${activeChannel.name}`
: activeChannelId
? `#${activeChannelId}`
: "[NO CHANNEL SELECTED]"}
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
<span>
{activeChannel
? `# ${activeChannel.name}`
: activeChannelId
? `#${activeChannelId}`
: "[NO CHANNEL SELECTED]"}
</span>
{activeChannelId && (
<button
onClick={() => setShowSearch((prev) => !prev)}
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
title="Search messages"
>
[SEARCH]
</button>
)}
</div>
{showSearch && activeChannelId && activeChannel && (
<MessageSearch
channelId={activeChannelId}
channelName={activeChannel.name}
onClose={() => setShowSearch(false)}
/>
)}
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
{!isLoading && messages.length === 0 && (
@@ -179,6 +273,7 @@ export function ChatArea() {
<span className="text-gb-fg">
{renderContent(message.content, memberUsernames)}
</span>
{renderEmbeds(message.embeds)}
</div>
))}
<div ref={bottomRef} />
@@ -196,6 +291,11 @@ export function ChatArea() {
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
</div>
)}
{slowmodeRemaining > 0 && (
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono">
SLOWMODE: wait {slowmodeRemaining}s
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<div className="flex-1 min-w-0 relative">
@@ -206,7 +306,7 @@ export function ChatArea() {
onChange={handleInputChange}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!activeChannelId}
disabled={!activeChannelId || slowmodeRemaining > 0}
/>
{mentionQuery !== null && (
<MentionDropdown
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import { useConversationStore } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts";
import { NewConversationModal } from "./NewConversationModal.tsx";
function otherMemberName(conv: { members: { id: string; username: string }[] }, currentUserId: string) {
const other = conv.members.find((m) => m.id !== currentUserId);
return other?.username || "unknown";
}
export function ConversationList() {
const conversations = useConversationStore((s) => s.conversations);
const activeId = useConversationStore((s) => s.activeConversationId);
const fetchConversations = useConversationStore((s) => s.fetchConversations);
const setActive = useConversationStore((s) => s.setActiveConversation);
const currentUser = useAuthStore((s) => s.user);
const [showNew, setShowNew] = useState(false);
useEffect(() => {
fetchConversations();
}, [fetchConversations]);
return (
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate flex items-center justify-between">
<span>[DIRECT MESSAGES]</span>
<button
onClick={() => setShowNew(true)}
className="terminal-button text-xs"
title="New conversation"
>
[+]
</button>
</div>
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
{conversations.length === 0 && (
<p className="text-gb-fg-f">[no conversations]</p>
)}
{conversations.map((conv) => {
const name =
conv.type === "group_dm"
? conv.name || conv.members.map((m) => m.username).join(", ")
: otherMemberName(conv, currentUser?.id || "");
return (
<button
key={conv.id}
onClick={() => setActive(conv.id)}
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
conv.id === activeId
? "terminal-active"
: "hover:bg-gb-bg-t text-gb-fg-s"
}`}
>
<span className="text-gb-fg-f">@</span>
<span className="truncate">{name}</span>
</button>
);
})}
</div>
{showNew && <NewConversationModal onClose={() => setShowNew(false)} />}
</div>
);
}
+25 -1
View File
@@ -16,10 +16,21 @@ interface ChannelApiResponse {
position: number;
}
const SLOWMODE_OPTIONS = [
{ label: 'Off', value: 0 },
{ label: '5 seconds', value: 5 },
{ label: '10 seconds', value: 10 },
{ label: '30 seconds', value: 30 },
{ label: '1 minute', value: 60 },
{ label: '2 minutes', value: 120 },
{ label: '5 minutes', value: 300 },
];
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
const [name, setName] = useState('');
const [type, setType] = useState<'text' | 'voice'>('text');
const [category, setCategory] = useState('general');
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -41,10 +52,11 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
setLoading(true);
setError(null);
try {
const result = await api.post<ChannelApiResponse>(`/servers/${serverId}/channels`, {
const result = await api.post<ChannelApiResponse>("/servers/" + serverId + "/channels", {
name: name.trim(),
type,
category: category.trim() || 'general',
slowmode_seconds: slowmodeSeconds,
});
const newChannel = {
id: result.id,
@@ -110,6 +122,18 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
className="terminal-input w-full"
/>
</div>
<div>
<label className="text-xs text-gb-fg-f block mb-1">SLOWMODE:</label>
<select
value={slowmodeSeconds}
onChange={(e) => setSlowmodeSeconds(parseInt(e.target.value, 10))}
className="terminal-input w-full"
>
{SLOWMODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{error && <div className="text-xs text-gb-red">{error}</div>}
<button
type="submit"
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
function formatTime(iso: string): string {
const date = new Date(iso);
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
function renderDMContent(content: string) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
p: ({ ...props }) => <span {...props} className="inline" />,
}}
>
{content}
</ReactMarkdown>
);
}
export function DMChat() {
const { conversationId } = useParams<{ conversationId: string }>();
const activeId = useConversationStore((s) => s.activeConversationId);
const setActive = useConversationStore((s) => s.setActiveConversation);
const conversations = useConversationStore((s) => s.conversations);
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
const fetchMessages = useConversationStore((s) => s.fetchMessages);
const sendMessage = useConversationStore((s) => s.sendMessage);
const fetchConversations = useConversationStore((s) => s.fetchConversations);
const currentUser = useAuthStore((s) => s.user);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const id = conversationId || activeId;
const conversation = conversations.find((c) => c.id === id);
const messages = id ? messagesByConv[id] || [] : [];
useEffect(() => {
fetchConversations();
}, [fetchConversations]);
useEffect(() => {
if (id) {
setActive(id);
fetchMessages(id);
setError(null);
}
}, [id, setActive, fetchMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!id || !input.trim()) return;
setError(null);
try {
await sendMessage(id, input.trim());
setInput("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send");
}
};
const title = conversation
? conversation.type === "group_dm"
? conversation.name || conversation.members.map((m) => m.username).join(", ")
: conversation.members.find((m) => m.id !== currentUser?.id)?.username || "DM"
: id
? "[LOADING...]"
: "[NO CONVERSATION]";
return (
<div className="flex flex-col h-full bg-gb-bg">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
@ {title}
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{!id && <p className="text-gb-fg-f">[select a conversation]</p>}
{id && messages.length === 0 && <p className="text-gb-fg-f">[no messages]</p>}
{messages.map((msg: ConversationMessage) => (
<div key={msg.id} className="break-words">
<span className="text-gb-fg-f">[{formatTime(msg.created_at)}]</span>{" "}
<span className="text-gb-aqua">&lt;{msg.author_username}&gt;</span>{" "}
<span className="text-gb-fg">{renderDMContent(msg.content)}</span>
</div>
))}
<div ref={bottomRef} />
</div>
{error && (
<div className="px-3 pb-1">
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!id}
/>
</form>
</div>
);
}
+33 -13
View File
@@ -2,13 +2,13 @@ import { useEffect, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore, type UserStatus } from '../stores/auth.ts';
import { useWebSocketStore } from '../stores/ws.ts';
import { useServerStore } from '../stores/server.ts';
import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx';
import { ConversationList } from './ConversationList.tsx';
import { MemberList } from './MemberList.tsx';
import { VoicePanel } from './VoicePanel.tsx';
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
function statusColor(status: UserStatus): string {
switch (status) {
case 'online':
@@ -21,11 +21,9 @@ function statusColor(status: UserStatus): string {
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);
@@ -35,22 +33,21 @@ export function Layout() {
const updateProfile = useAuthStore((state) => state.updateProfile);
const wsConnect = useWebSocketStore((s) => s.connect);
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
const setActiveServer = useServerStore((s) => s.setActiveServer);
const navigate = useNavigate();
const location = useLocation();
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [dmMode, setDmMode] = useState(false);
useEffect(() => {
fetchMe();
wsConnect();
return () => { wsDisconnect(); };
}, [fetchMe, wsConnect, wsDisconnect]);
useEffect(() => {
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
navigate('/login', { replace: true });
}
}, [isLoading, isAuthenticated, location.pathname, navigate]);
const handleStatusChange = async (status: UserStatus) => {
setShowStatusMenu(false);
try {
@@ -59,9 +56,7 @@ export function Layout() {
// Error is surfaced via auth store; menu closes optimistically.
}
};
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">
@@ -69,11 +64,9 @@ export function Layout() {
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col p-2">
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0">
@@ -114,8 +107,35 @@ export function Layout() {
</div>
</div>
<div className="flex-1 flex min-h-0">
<div className="flex flex-col items-center w-16 bg-gb-bg-h border-r border-gb-bg-t py-2 gap-2 shrink-0">
<button
onClick={() => setDmMode(false)}
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
!dmMode
? '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'
}`}
title="Servers"
>
[S]
</button>
<button
onClick={() => {
setDmMode(true);
setActiveServer(null);
}}
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
dmMode
? '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'
}`}
title="Direct Messages"
>
[@]
</button>
</div>
<ServerBar />
<ChannelList />
{dmMode ? <ConversationList /> : <ChannelList />}
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<VoicePanel />
@@ -124,7 +144,7 @@ export function Layout() {
</div>
</div>
</div>
<MemberList />
{!dmMode && <MemberList />}
</div>
<div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s">
<span>TERM v1.0</span>
+138
View File
@@ -0,0 +1,138 @@
import { useState } from "react";
interface MemberContextMenuProps {
memberId: string;
username: string;
canKick: boolean;
canBan: boolean;
canMute: boolean;
onMessage?: () => void;
onKick?: (reason: string) => void;
onBan?: (reason: string) => void;
onMute?: (duration: string, reason: string) => void;
onClose: () => void;
}
export function MemberContextMenu({
username,
canKick,
canBan,
canMute,
onMessage,
onKick,
onBan,
onMute,
onClose,
}: MemberContextMenuProps) {
const [mode, setMode] = useState<"menu" | "kick" | "ban" | "mute">("menu");
const [reason, setReason] = useState("");
const [duration, setDuration] = useState("1h");
if (mode === "kick") {
return (
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t p-3 font-mono text-xs shadow-lg min-w-[200px]">
<div className="text-gb-orange mb-2">KICK {username.toUpperCase()}</div>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="reason (optional)"
className="terminal-input w-full mb-2"
/>
<div className="flex gap-2">
<button onClick={() => onKick?.(reason)} className="flex-1 bg-gb-red text-gb-bg py-1 hover:bg-gb-orange">
[KICK]
</button>
<button onClick={() => setMode("menu")} className="flex-1 bg-gb-bg-t text-gb-fg py-1 hover:bg-gb-bg-s">
[BACK]
</button>
</div>
</div>
);
}
if (mode === "ban") {
return (
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t p-3 font-mono text-xs shadow-lg min-w-[200px]">
<div className="text-gb-orange mb-2">BAN {username.toUpperCase()}</div>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="reason (optional)"
className="terminal-input w-full mb-2"
/>
<div className="flex gap-2">
<button onClick={() => onBan?.(reason)} className="flex-1 bg-gb-red text-gb-bg py-1 hover:bg-gb-orange">
[BAN]
</button>
<button onClick={() => setMode("menu")} className="flex-1 bg-gb-bg-t text-gb-fg py-1 hover:bg-gb-bg-s">
[BACK]
</button>
</div>
</div>
);
}
if (mode === "mute") {
return (
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t p-3 font-mono text-xs shadow-lg min-w-[200px]">
<div className="text-gb-orange mb-2">MUTE {username.toUpperCase()}</div>
<select
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="w-full mb-2 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
>
<option value="15m">15 minutes</option>
<option value="1h">1 hour</option>
<option value="6h">6 hours</option>
<option value="1d">1 day</option>
<option value="7d">7 days</option>
<option value="">permanent</option>
</select>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="reason (optional)"
className="terminal-input w-full mb-2"
/>
<div className="flex gap-2">
<button onClick={() => onMute?.(duration, reason)} className="flex-1 bg-gb-yellow text-gb-bg py-1 hover:bg-gb-orange">
[MUTE]
</button>
<button onClick={() => setMode("menu")} className="flex-1 bg-gb-bg-t text-gb-fg py-1 hover:bg-gb-bg-s">
[BACK]
</button>
</div>
</div>
);
}
return (
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t py-1 font-mono text-xs shadow-lg min-w-[140px]">
<button onClick={onMessage} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-fg">
Message
</button>
{canKick && (
<button onClick={() => setMode("kick")} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-yellow">
Kick
</button>
)}
{canMute && (
<button onClick={() => setMode("mute")} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-orange">
Mute
</button>
)}
{canBan && (
<button onClick={() => setMode("ban")} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-red">
Ban
</button>
)}
<div className="border-t border-gb-bg-t my-1" />
<button onClick={onClose} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-fg-f">
Cancel
</button>
</div>
);
}
+59 -6
View File
@@ -1,8 +1,11 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useServerStore } from "../stores/server.ts";
import { useAuthStore } from "../stores/auth.ts";
import { useMemberStore, type Member } from "../stores/member.ts";
import { usePresenceStore } from "../stores/presence.ts";
import type { UserStatus } from "../stores/auth.ts";
import { MemberContextMenu } from "./MemberContextMenu.tsx";
import { useConversationStore } from "../stores/conversation.ts";
function statusIcon(status: UserStatus): string {
switch (status) {
@@ -46,13 +49,63 @@ function usernameColor(status: UserStatus): string {
function MemberRow({ member }: { member: Member }) {
const presence = usePresenceStore((s) => s.presences[member.id]);
const status = presence?.status ?? member.status;
const [menuOpen, setMenuOpen] = useState(false);
const currentUser = useAuthStore((s) => s.user);
const userPerms = useServerStore((s) => s.userPermissions);
const activeServerId = useServerStore((s) => s.activeServerId);
const createConversation = useConversationStore((s) => s.createConversation);
const canKick = userPerms?.kick_members ?? false;
const canBan = userPerms?.ban_members ?? false;
const canMute = userPerms?.mute_members ?? false;
const isSelf = currentUser?.id === member.id;
return (
<div className="flex items-center gap-2 px-2 py-1">
<span className={statusColor(status)}>{statusIcon(status)}</span>
<span className={`truncate ${usernameColor(status)}`}>
{member.display_name || member.username}
</span>
<div className="relative">
<button
onClick={() => setMenuOpen((v) => !v)}
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t"
>
<span className={statusColor(status)}>{statusIcon(status)}</span>
<span className={`truncate ${usernameColor(status)}`}>
{member.display_name || member.username}
</span>
<span className="ml-auto text-gb-fg-f text-xs">[...]</span>
</button>
{menuOpen && !isSelf && activeServerId && (
<MemberContextMenu
memberId={member.id}
username={member.username}
canKick={canKick}
canBan={canBan}
canMute={canMute}
onMessage={() => {
createConversation([member.id]);
setMenuOpen(false);
}}
onKick={(_reason) => {
fetch(`/api/v1/servers/${activeServerId}/members/${member.id}`, { method: "DELETE", credentials: "include" })
.then(() => setMenuOpen(false));
}}
onBan={(reason) => {
fetch(`/api/v1/servers/${activeServerId}/bans`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: member.id, reason }),
}).then(() => setMenuOpen(false));
}}
onMute={(duration, reason) => {
fetch(`/api/v1/servers/${activeServerId}/mutes`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: member.id, duration, reason }),
}).then(() => setMenuOpen(false));
}}
onClose={() => setMenuOpen(false)}
/>
)}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useState, useRef, useEffect } from "react";
import { useMessageStore } from "../stores/message.ts";
interface MessageSearchProps {
channelId: string;
channelName: string;
onClose: () => void;
onJump?: (messageId: string) => void;
}
export function MessageSearch({ channelId, channelName, onClose, onJump }: MessageSearchProps) {
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const results = useMessageStore((s) => s.searchResultsByChannel[channelId] || []);
const searchMessages = useMessageStore((s) => s.searchMessages);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
const t = setTimeout(() => {
if (query.trim()) {
searchMessages(channelId, query.trim());
setSelectedIndex(0);
}
}, 200);
return () => clearTimeout(t);
}, [query, channelId, searchMessages]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => (i + 1) % results.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => (i - 1 + results.length) % results.length);
} else if (e.key === "Enter") {
e.preventDefault();
const msg = results[selectedIndex];
if (msg && onJump) {
onJump(msg.id);
}
onClose();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [results, selectedIndex, onClose, onJump]);
return (
<div className="absolute inset-x-0 top-10 z-20 bg-gb-bg border border-gb-bg-t shadow-lg mx-3">
<div className="px-3 py-2 border-b border-gb-bg-t flex items-center justify-between">
<span className="text-xs text-gb-orange font-mono">SEARCH #{channelName.toUpperCase()}</span>
<button onClick={onClose} className="text-xs text-gb-red hover:text-gb-orange">[x]</button>
</div>
<div className="p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="search messages..."
className="terminal-input w-full text-sm"
/>
</div>
<div className="max-h-64 overflow-y-auto font-mono text-xs">
{results.length === 0 && query.trim() && (
<div className="px-3 py-2 text-gb-fg-f">[no results]</div>
)}
{results.map((msg, idx) => (
<button
key={msg.id}
onClick={() => {
onJump?.(msg.id);
onClose();
}}
className={`w-full text-left px-3 py-2 border-b border-gb-bg-t last:border-b-0 ${
idx === selectedIndex ? "bg-gb-bg-s" : "hover:bg-gb-bg-s/50"
}`}
>
<div className="flex items-center gap-2 text-gb-fg-f">
<span className="text-gb-aqua">&lt;{msg.author_username}&gt;</span>
<span className="text-gb-fg-s">{new Date(msg.created_at).toLocaleString()}</span>
</div>
<div className="text-gb-fg truncate">{msg.content}</div>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { useState, useMemo } from "react";
import { useConversationStore } from "../stores/conversation.ts";
import { useMemberStore } from "../stores/member.ts";
import { useServerStore } from "../stores/server.ts";
import { useAuthStore } from "../stores/auth.ts";
interface NewConversationModalProps {
onClose: () => void;
}
export function NewConversationModal({ onClose }: NewConversationModalProps) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const createConversation = useConversationStore((s) => s.createConversation);
const activeServerId = useServerStore((s) => s.activeServerId);
const membersByServer = useMemberStore((s) => s.membersByServer);
const currentUserId = useAuthStore((s) => s.user?.id);
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
const filtered = useMemo(() => {
const q = query.toLowerCase();
return members.filter(
(m) =>
m.id !== currentUserId &&
(m.username.toLowerCase().includes(q) ||
(m.display_name || "").toLowerCase().includes(q)),
);
}, [members, query, currentUserId]);
const toggle = (id: string) => {
setSelected((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const handleCreate = async () => {
if (selected.length === 0) return;
await createConversation(selected);
onClose();
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-gb-bg border border-gb-bg-t p-4 min-w-[300px] font-mono"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gb-orange">NEW DIRECT MESSAGE</span>
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="search members..."
className="terminal-input w-full mb-3 text-sm"
/>
<div className="max-h-48 overflow-y-auto space-y-1 mb-3">
{filtered.map((m) => (
<button
key={m.id}
onClick={() => toggle(m.id)}
className={`w-full text-left px-2 py-1 text-sm ${
selected.includes(m.id)
? "bg-gb-aqua text-gb-bg"
: "hover:bg-gb-bg-t text-gb-fg-s"
}`}
>
{m.username}
</button>
))}
{filtered.length === 0 && (
<p className="text-gb-fg-f text-xs">[no members found]</p>
)}
</div>
<button
onClick={handleCreate}
disabled={selected.length === 0}
className="w-full px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
>
[START CONVERSATION]
</button>
</div>
</div>
);
}
+24 -5
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useServerStore } from '../stores/server.ts';
import { useChannelStore } from '../stores/channel.ts';
import { useLayoutStore } from '../stores/layout.ts';
import { CreateServerModal } from './CreateServerModal.tsx';
import { JoinServerModal } from './JoinServerModal.tsx';
@@ -18,6 +19,7 @@ export function ServerBar() {
const fetchServers = useServerStore((state) => state.fetchServers);
const setActiveServer = useServerStore((state) => state.setActiveServer);
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
const { isDM, setDM } = useLayoutStore();
const [showCreate, setShowCreate] = useState(false);
const [showJoin, setShowJoin] = useState(false);
@@ -28,26 +30,43 @@ export function ServerBar() {
const handleSelect = (id: string) => {
setActiveServer(id);
setActiveChannel(null);
setDM(false);
};
const handleDM = () => {
setActiveServer(null);
setActiveChannel(null);
setDM(true);
};
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">
<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>
<div className="w-8 h-px bg-gb-bg-t" />
{servers.map((server) => (
<button
key={server.id}
onClick={() => handleSelect(server.id)}
className={`w-11 h-11 flex items-center justify-center font-mono text-sm border ${
server.id === activeServerId
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'
}`}
: '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)}]`
'[' + getInitials(server.name) + ']'
)}
</button>
))}