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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user