bb650ac2a0
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
102 lines
3.2 KiB
TypeScript
102 lines
3.2 KiB
TypeScript
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>
|
|
);
|
|
}
|