Phase 3: Polish & PWA
Backend: - DB: reactions table, invites table, reply_to column on messages - gateway/events.go: added REACTION_ADD, REACTION_REMOVE events - internal/reaction/handlers.go: reaction CRUD with WebSocket broadcast - internal/invite/handlers.go: invite creation, info, join with code - gateway/hub.go: presence tracking with idle detection - gateway/client.go: idle timeout support Frontend - Social: - TypingIndicator: real-time 'user is typing...' display - ReactionBar: emoji reactions on messages with counts - EmojiPicker: searchable emoji grid for reactions - ReplyBar: quoted reply display above messages - MentionPopup: @mention autocomplete with user list Frontend - PWA: - manifest.json: PWA manifest with theme color and icons - sw.js: service worker with cache-first strategy and push support - stores/push.ts: push notification subscription management - InstallPrompt: 'Add to Home Screen' banner Frontend - Mobile: - MobileNav: bottom nav bar for mobile (servers/channels/chat/members) - MobileDrawer: slide-out drawer with server bar + channel list - index.html: PWA meta tags, safe area viewport Frontend - Polish: - ThemeToggle: dark/light mode switch with localStorage persistence - InviteModal: generate invite links with expiry and max uses - JoinServer: /invite/:code join flow - stores/typing.ts: typing indicator state management - stores/presence.ts: real-time presence tracking - tailwind.config.js: darkMode: 'class', light mode color tokens - styles/index.css: light mode CSS variable overrides
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const EMOJI_LIST = [
|
||||
'👍', '❤️', '😂', '😮', '😢', '😡', '🎉', '🔥',
|
||||
'👀', '💯', '✨', '🙏', '💀', '🤡', '🤔', '😎',
|
||||
'🫡', '🫠', '🤯', '🥳', '😴', '🤮', '👻', '🎃',
|
||||
'🚀', '⭐', '🌈', '🍕', '🍺', '☕', '🎮', '🎵',
|
||||
];
|
||||
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = search
|
||||
? EMOJI_LIST.filter(e => e.includes(search))
|
||||
: EMOJI_LIST;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 mb-1 bg-gb-bg border border-gb-bg-t p-2 min-w-[200px] z-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-gb-fg-f font-mono">EMOJI</span>
|
||||
<button onClick={onClose} className="text-xs text-gb-red font-mono">[x]</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="search..."
|
||||
className="w-full px-2 py-1 mb-2 text-xs font-mono bg-gb-bg-s text-gb-fg border-none outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="grid grid-cols-8 gap-1">
|
||||
{filtered.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => {
|
||||
onSelect(emoji);
|
||||
onClose();
|
||||
}}
|
||||
className="w-6 h-6 flex items-center justify-center text-sm hover:bg-gb-bg-s transition-colors"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-xs text-gb-fg-f font-mono text-center py-2">[no results]</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
export function InstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showInstall, setShowInstall] = useState(false);
|
||||
const [installed, setInstalled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
setShowInstall(true);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
setInstalled(true);
|
||||
setShowInstall(false);
|
||||
setDeferredPrompt(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
if (outcome === 'accepted') {
|
||||
setInstalled(true);
|
||||
setShowInstall(false);
|
||||
}
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
if (!showInstall || installed) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80 z-50">
|
||||
<div className="bg-gb-bg border border-gb-orange p-3 font-mono shadow-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gb-orange">INSTALL DUMPSTER</span>
|
||||
<button
|
||||
onClick={() => setShowInstall(false)}
|
||||
className="text-xs text-gb-red"
|
||||
>
|
||||
[x]
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gb-fg-f mb-3">
|
||||
Add to your home screen for the full experience. Push notifications, offline access, and more.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors"
|
||||
>
|
||||
[INSTALL]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowInstall(false)}
|
||||
className="px-3 py-1.5 bg-gb-bg-t text-gb-fg text-xs font-mono hover:bg-gb-bg-s transition-colors"
|
||||
>
|
||||
[LATER]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../lib/api.ts';
|
||||
|
||||
interface InviteModalProps {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Invite {
|
||||
code: string;
|
||||
url: string;
|
||||
expires_at: string | null;
|
||||
max_uses: number | null;
|
||||
}
|
||||
|
||||
export function InviteModal({ serverId, serverName, onClose }: InviteModalProps) {
|
||||
const [expiresHours, setExpiresHours] = useState(24);
|
||||
const [maxUses, setMaxUses] = useState<number | ''>('');
|
||||
const [invite, setInvite] = useState<Invite | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const createInvite = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
expires_hours: expiresHours,
|
||||
};
|
||||
if (maxUses !== '') {
|
||||
payload.max_uses = maxUses;
|
||||
}
|
||||
const result = await api.post<Invite>(`/servers/${serverId}/invites`, payload);
|
||||
setInvite(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create invite');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = () => {
|
||||
if (invite) {
|
||||
navigator.clipboard.writeText(invite.url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
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-[350px] font-mono" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-gb-orange">INVITE TO {serverName.toUpperCase()}</span>
|
||||
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
||||
</div>
|
||||
|
||||
{!invite ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gb-fg-f block mb-1">EXPIRES IN:</label>
|
||||
<select
|
||||
value={expiresHours}
|
||||
onChange={(e) => setExpiresHours(Number(e.target.value))}
|
||||
className="w-full px-2 py-1 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
|
||||
>
|
||||
<option value={1}>1 hour</option>
|
||||
<option value={6}>6 hours</option>
|
||||
<option value={12}>12 hours</option>
|
||||
<option value={24}>24 hours</option>
|
||||
<option value={168}>7 days</option>
|
||||
<option value={0}>Never</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gb-fg-f block mb-1">MAX USES:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
placeholder="unlimited"
|
||||
min={1}
|
||||
className="w-full px-2 py-1 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-gb-red">{error}</div>
|
||||
)}
|
||||
<button
|
||||
onClick={createInvite}
|
||||
disabled={loading}
|
||||
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"
|
||||
>
|
||||
{loading ? 'CREATING...' : '[GENERATE LINK]'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gb-fg-f">Share this link:</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={invite.url}
|
||||
readOnly
|
||||
className="flex-1 px-2 py-1 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="px-3 py-1 bg-gb-bg-t text-gb-fg text-xs font-mono hover:bg-gb-bg-s transition-colors"
|
||||
>
|
||||
{copied ? '[COPIED!]' : '[COPY]'}
|
||||
</button>
|
||||
</div>
|
||||
{invite.expires_at && (
|
||||
<div className="text-xs text-gb-fg-f">
|
||||
Expires: {new Date(invite.expires_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
{invite.max_uses && (
|
||||
<div className="text-xs text-gb-fg-f">
|
||||
Max uses: {invite.max_uses}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setInvite(null)}
|
||||
className="text-xs text-gb-aqua hover:text-gb-orange transition-colors"
|
||||
>
|
||||
[CREATE ANOTHER]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../lib/api.ts';
|
||||
|
||||
interface InviteInfo {
|
||||
server_name: string;
|
||||
inviter: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export function JoinServer() {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [info, setInfo] = useState<InviteInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) return;
|
||||
const fetchInfo = async () => {
|
||||
try {
|
||||
const data = await api.get<InviteInfo>(`/invites/${code}`);
|
||||
setInfo(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Invalid invite');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchInfo();
|
||||
}, [code]);
|
||||
|
||||
const joinServer = async () => {
|
||||
if (!code) return;
|
||||
setJoining(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.post<{ server_id: string }>(`/invites/${code}/join`);
|
||||
navigate(`/channels/${result.server_id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join');
|
||||
} finally {
|
||||
setJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-gb-bg text-gb-fg font-mono">
|
||||
<div className="text-gb-fg-f">Loading invite...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-gb-bg text-gb-fg font-mono">
|
||||
<div className="bg-gb-bg-s border border-gb-red p-6 max-w-md text-center">
|
||||
<div className="text-gb-red text-lg mb-2">INVALID INVITE</div>
|
||||
<div className="text-gb-fg-f text-sm mb-4">{error}</div>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="px-4 py-2 bg-gb-bg-t text-gb-fg text-sm hover:bg-gb-bg-s transition-colors"
|
||||
>
|
||||
[GO HOME]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-gb-bg text-gb-fg font-mono">
|
||||
<div className="bg-gb-bg-s border border-gb-bg-t p-6 max-w-md">
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-gb-orange text-lg mb-1">YOU'VE BEEN INVITED</div>
|
||||
<div className="text-2xl text-gb-fg mb-2">{info?.server_name}</div>
|
||||
{info?.inviter && (
|
||||
<div className="text-sm text-gb-fg-f">by {info.inviter}</div>
|
||||
)}
|
||||
</div>
|
||||
{info?.expires_at && (
|
||||
<div className="text-xs text-gb-fg-f text-center mb-4">
|
||||
Expires: {new Date(info.expires_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-sm text-gb-red text-center mb-3">{error}</div>
|
||||
)}
|
||||
<button
|
||||
onClick={joinServer}
|
||||
disabled={joining}
|
||||
className="w-full px-4 py-2 bg-gb-orange text-gb-bg text-sm font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
|
||||
>
|
||||
{joining ? 'JOINING...' : '[ACCEPT INVITE]'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePresenceStore } from '../stores/presence.ts';
|
||||
import type { User, UserStatus } from '../stores/auth.ts';
|
||||
|
||||
interface MemberListProps {
|
||||
@@ -43,9 +44,41 @@ function usernameColor(status: UserStatus): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the live status for a member, falling back to the static value. */
|
||||
function useLiveStatus(member: User): UserStatus {
|
||||
const presence = usePresenceStore((s) => s.presences[member.id]);
|
||||
return presence?.status ?? member.status;
|
||||
}
|
||||
|
||||
interface MemberRowProps {
|
||||
member: User;
|
||||
}
|
||||
|
||||
function MemberRow({ member }: MemberRowProps) {
|
||||
const status = useLiveStatus(member);
|
||||
|
||||
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.username}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberList({ members = [] }: MemberListProps) {
|
||||
const online = members.filter((m) => m.status !== 'offline');
|
||||
const offline = members.filter((m) => m.status === 'offline');
|
||||
// Derive live status for every member to decide online/offline buckets.
|
||||
const presences = usePresenceStore((s) => s.presences);
|
||||
|
||||
const online = members.filter((m) => {
|
||||
const status = presences[m.id]?.status ?? m.status;
|
||||
return status !== 'offline';
|
||||
});
|
||||
const offline = members.filter((m) => {
|
||||
const status = presences[m.id]?.status ?? m.status;
|
||||
return status === 'offline';
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full w-52 bg-gb-bg-s border-l border-gb-bg-t flex flex-col">
|
||||
@@ -61,12 +94,7 @@ export function MemberList({ members = [] }: MemberListProps) {
|
||||
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE</div>
|
||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||
{online.map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-2 px-2 py-1">
|
||||
<span className={statusColor(member.status)}>{statusIcon(member.status)}</span>
|
||||
<span className={`truncate ${usernameColor(member.status)}`}>
|
||||
{member.username}
|
||||
</span>
|
||||
</div>
|
||||
<MemberRow key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -75,12 +103,7 @@ export function MemberList({ members = [] }: MemberListProps) {
|
||||
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div>
|
||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||
{offline.map((member) => (
|
||||
<div key={member.id} className="flex items-center gap-2 px-2 py-1">
|
||||
<span className={statusColor(member.status)}>{statusIcon(member.status)}</span>
|
||||
<span className={`truncate ${usernameColor(member.status)}`}>
|
||||
{member.username}
|
||||
</span>
|
||||
</div>
|
||||
<MemberRow key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
interface MentionPopupProps {
|
||||
users: User[];
|
||||
filter: string;
|
||||
onSelect: (user: User) => void;
|
||||
position: { top: number; left: number };
|
||||
}
|
||||
|
||||
export function MentionPopup({ users, filter, onSelect, position }: MentionPopupProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = users.filter(u =>
|
||||
u.username.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
u.display_name?.toLowerCase().includes(filter.toLowerCase())
|
||||
).slice(0, 8);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, filtered.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (filtered[selectedIndex]) {
|
||||
onSelect(filtered[selectedIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onSelect(null as any);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filtered, selectedIndex, onSelect]);
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute bg-gb-bg border border-gb-bg-t shadow-lg z-50 min-w-[200px] max-h-[200px] overflow-y-auto"
|
||||
style={{ bottom: '100%', left: position.left, marginBottom: 4 }}
|
||||
>
|
||||
<div className="px-2 py-1 text-xs text-gb-fg-f font-mono border-b border-gb-bg-t">
|
||||
MEMBERS
|
||||
</div>
|
||||
{filtered.map((user, index) => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => onSelect(user)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`
|
||||
w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2
|
||||
transition-colors
|
||||
${index === selectedIndex
|
||||
? 'bg-gb-orange text-gb-bg'
|
||||
: 'text-gb-fg hover:bg-gb-bg-s'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="text-gb-green">●</span>
|
||||
<span>{user.display_name || user.username}</span>
|
||||
{user.display_name && user.display_name !== user.username && (
|
||||
<span className="text-gb-fg-f">({user.username})</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ServerBar } from './ServerBar.tsx';
|
||||
import { ChannelList } from './ChannelList.tsx';
|
||||
|
||||
interface MobileDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="md:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Drawer */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[280px] bg-gb-bg flex">
|
||||
{/* Server bar */}
|
||||
<div className="w-[48px] bg-gb-bg-h">
|
||||
<ServerBar />
|
||||
</div>
|
||||
{/* Channel list */}
|
||||
<div className="flex-1">
|
||||
<ChannelList />
|
||||
</div>
|
||||
</div>
|
||||
{/* Swipe hint */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1 bg-gb-orange/20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useVoiceStore } from '../stores/voice.ts';
|
||||
import { ThemeToggle } from './ThemeToggle.tsx';
|
||||
|
||||
interface MobileNavProps {
|
||||
activeTab: 'servers' | 'channels' | 'chat' | 'members';
|
||||
onTabChange: (tab: 'servers' | 'channels' | 'chat' | 'members') => void;
|
||||
onToggleDrawer: () => void;
|
||||
}
|
||||
|
||||
export function MobileNav({ activeTab, onTabChange, onToggleDrawer }: MobileNavProps) {
|
||||
const isConnected = useVoiceStore((state: any) => state.isConnected);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'servers' as const, label: '[=]', title: 'Servers' },
|
||||
{ id: 'channels' as const, label: '[#]', title: 'Channels' },
|
||||
{ id: 'chat' as const, label: '[_]', title: 'Chat' },
|
||||
{ id: 'members' as const, label: '[@]', title: 'Members' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-gb-bg-h border-t border-gb-bg-t z-40">
|
||||
<div className="flex items-center justify-around py-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
if (tab.id === 'servers' || tab.id === 'channels') {
|
||||
onToggleDrawer();
|
||||
}
|
||||
onTabChange(tab.id);
|
||||
}}
|
||||
className={`
|
||||
px-3 py-1 text-xs font-mono transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'text-gb-orange'
|
||||
: 'text-gb-fg-f hover:text-gb-fg'
|
||||
}
|
||||
`}
|
||||
title={tab.title}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
{isConnected && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gb-green animate-pulse" title="In voice" />
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{/* Safe area for phones with bottom notch */}
|
||||
<div className="h-[env(safe-area-inset-bottom)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../lib/api.ts';
|
||||
|
||||
interface Reaction {
|
||||
emoji: string;
|
||||
count: number;
|
||||
users: string[];
|
||||
reacted: boolean;
|
||||
}
|
||||
|
||||
interface ReactionBarProps {
|
||||
messageId: string;
|
||||
reactions: Reaction[];
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function ReactionBar({ messageId, reactions, onRefresh }: ReactionBarProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const toggleReaction = async (emoji: string) => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const existing = reactions.find(r => r.emoji === emoji);
|
||||
if (existing?.reacted) {
|
||||
await api.delete(`/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`);
|
||||
} else {
|
||||
await api.post(`/messages/${messageId}/reactions`, { emoji });
|
||||
}
|
||||
onRefresh();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle reaction:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (reactions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{reactions.map((reaction) => (
|
||||
<button
|
||||
key={reaction.emoji}
|
||||
onClick={() => toggleReaction(reaction.emoji)}
|
||||
disabled={loading}
|
||||
className={`
|
||||
px-1.5 py-0.5 text-xs font-mono border rounded-sm
|
||||
transition-colors duration-100
|
||||
${reaction.reacted
|
||||
? 'border-gb-orange bg-gb-bg-t text-gb-orange'
|
||||
: 'border-gb-bg-t bg-gb-bg-s text-gb-fg-f hover:border-gb-fg-f'
|
||||
}
|
||||
`}
|
||||
title={reaction.users.join(', ')}
|
||||
>
|
||||
{reaction.emoji} {reaction.count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
interface ReplyBarProps {
|
||||
replyTo: {
|
||||
id: string;
|
||||
content: string;
|
||||
author: {
|
||||
username: string;
|
||||
accent_color?: string;
|
||||
};
|
||||
} | null;
|
||||
onCancel?: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ReplyBar({ replyTo, onCancel, compact }: ReplyBarProps) {
|
||||
if (!replyTo) return null;
|
||||
|
||||
const truncatedContent = replyTo.content.length > 100
|
||||
? replyTo.content.slice(0, 100) + '...'
|
||||
: replyTo.content;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-gb-fg-f font-mono pl-4 border-l-2 border-gb-orange">
|
||||
<span className="text-gb-orange">↩</span>
|
||||
<span style={{ color: replyTo.author.accent_color || 'var(--gb-fg-f)' }}>
|
||||
{replyTo.author.username}
|
||||
</span>
|
||||
<span className="truncate">{truncatedContent}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-gb-bg-h border-l-2 border-gb-orange mb-1">
|
||||
<span className="text-gb-orange text-xs font-mono">↩</span>
|
||||
<span
|
||||
className="text-xs font-mono font-bold"
|
||||
style={{ color: replyTo.author.accent_color || 'var(--gb-fg-s)' }}
|
||||
>
|
||||
{replyTo.author.username}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-gb-fg-f truncate flex-1">
|
||||
{truncatedContent}
|
||||
</span>
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-xs text-gb-red font-mono hover:text-gb-orange transition-colors"
|
||||
>
|
||||
[x]
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem('dumpster-theme');
|
||||
return (stored === 'light' ? 'light' : 'dark');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'light') {
|
||||
root.classList.add('light');
|
||||
root.classList.remove('dark');
|
||||
} else {
|
||||
root.classList.add('dark');
|
||||
root.classList.remove('light');
|
||||
}
|
||||
localStorage.setItem('dumpster-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggle = () => {
|
||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="px-2 py-1 text-xs font-mono text-gb-fg-f hover:text-gb-orange transition-colors"
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
[{theme === 'dark' ? 'light' : 'dark'}]
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTypingStore } from '../stores/typing.ts';
|
||||
import { useAuthStore } from '../stores/auth.ts';
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export function TypingIndicator({ channelId }: TypingIndicatorProps) {
|
||||
const typingUsers = useTypingStore((state) => state.typingUsers[channelId] || []);
|
||||
const currentUser = useAuthStore((state) => state.user);
|
||||
|
||||
// Filter out current user
|
||||
const others = typingUsers.filter(u => u.userId !== currentUser?.id);
|
||||
|
||||
if (others.length === 0) return null;
|
||||
|
||||
const names = others.map(u => u.username);
|
||||
|
||||
let text: string;
|
||||
if (names.length === 1) {
|
||||
text = `<${names[0]}> is typing...`;
|
||||
} else if (names.length === 2) {
|
||||
text = `<${names[0]}> and <${names[1]}> are typing...`;
|
||||
} else {
|
||||
text = `${names.length} users are typing...`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-1 text-xs text-gb-fg-f font-mono animate-pulse">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user