Profiles, Giphy, uploads, settings UI

Backend:
- Auth: split routes into RegisterPublicRoutes + RegisterProtectedRoutes
- Auth: PATCH /me for profile updates (display_name, bio, accent_color, status_text)
- Auth: Gravatar fallback for avatars on register
- DB: users table now has bio, accent_color, status_text columns
- giphy/client.go: Search() and GetTrending() against Giphy API
- upload/handlers.go: MinIO file upload + serve
- config: added GiphyConfig and MinIO config
- cmd/server: wired giphy (/gifs/search, /gifs/trending), upload (/upload), files (/files/*) routes

Frontend:
- auth store: updated User interface with new profile fields, added updateProfile()
- UserSettings.tsx: terminal-styled profile editor (display_name, bio, accent_color, status_text, avatar upload)
- GiphyPicker.tsx: terminal-styled GIF picker with search + trending
- ChatArea.tsx: integrated [GIF] button into message input
- App.tsx: imports UserSettings, added /settings route
This commit is contained in:
2026-06-28 16:06:08 -04:00
parent bb5a56816b
commit e69553af02
18 changed files with 1422 additions and 352 deletions
+20 -1
View File
@@ -1,7 +1,8 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
import { LoginForm } from './components/LoginForm.tsx';
import { Layout } from './components/Layout.tsx';
import { ChatArea } from './components/ChatArea.tsx';
import { UserSettings } from './components/UserSettings.tsx';
import { useAuthStore } from './stores/auth.ts';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -14,6 +15,24 @@ function App() {
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route
path="/settings"
element={
<ProtectedRoute>
<div className="h-full w-full flex flex-col bg-gb-bg">
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
[BACK]
</Link>
<span className="text-gb-orange font-mono text-sm">SETTINGS</span>
</header>
<div className="flex-1 overflow-hidden">
<UserSettings />
</div>
</div>
</ProtectedRoute>
}
/>
<Route
path="/"
element={
+26
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useMessageStore } from '../stores/message.ts';
import { useChannelStore } from '../stores/channel.ts';
import { GiphyPicker, type Gif } from './GiphyPicker';
function formatTime(iso: string): string {
const date = new Date(iso);
@@ -18,6 +19,7 @@ export function ChatArea() {
const fetchMessages = useMessageStore((state) => state.fetchMessages);
const sendMessage = useMessageStore((state) => state.sendMessage);
const [input, setInput] = useState('');
const [showGifPicker, setShowGifPicker] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -39,6 +41,13 @@ export function ChatArea() {
setInput('');
};
const handleGifSelect = async (gif: Gif) => {
if (!activeChannelId) return;
const content = `![${gif.title || 'GIF'}](${gif.url})`;
await sendMessage(activeChannelId, content);
setShowGifPicker(false);
};
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">
@@ -58,6 +67,14 @@ export function ChatArea() {
))}
<div ref={bottomRef} />
</div>
{showGifPicker && (
<div className="px-3 pb-1">
<GiphyPicker
onSelect={handleGifSelect}
onClose={() => setShowGifPicker(false)}
/>
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
<span className="text-gb-fg-f select-none">{'>'}</span>
<input
@@ -68,6 +85,15 @@ export function ChatArea() {
className="terminal-input"
disabled={!activeChannelId}
/>
<button
type="button"
onClick={() => setShowGifPicker((prev) => !prev)}
disabled={!activeChannelId}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed"
title="Toggle GIF picker"
>
[GIF]
</button>
</form>
</div>
);
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { api } from '../lib/api';
interface GifImage {
url: string;
}
interface Gif {
id: string;
title: string;
url: string;
images: {
fixed_height: GifImage;
original: GifImage;
};
username: string;
}
interface GiphyPickerProps {
onSelect: (gif: Gif) => void;
onClose: () => void;
}
export function GiphyPicker({ onSelect, onClose }: GiphyPickerProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Gif[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Fetch trending on mount
useEffect(() => {
const fetchTrending = async () => {
setLoading(true);
try {
const gifs = await api.get<Gif[]>('/gifs/trending');
setResults(gifs);
} catch {
setResults([]);
} finally {
setLoading(false);
}
};
fetchTrending();
inputRef.current?.focus();
}, []);
// Debounced search
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (!query.trim()) {
// If query cleared, reload trending
const fetchTrending = async () => {
setLoading(true);
try {
const gifs = await api.get<Gif[]>('/gifs/trending');
setResults(gifs);
} catch {
setResults([]);
} finally {
setLoading(false);
}
};
fetchTrending();
return;
}
debounceRef.current = setTimeout(async () => {
setLoading(true);
try {
const gifs = await api.get<Gif[]>(
`/gifs/search?q=${encodeURIComponent(query.trim())}`
);
setResults(gifs);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 300);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [query]);
const handleClick = useCallback(
(gif: Gif) => {
onSelect(gif);
},
[onSelect]
);
return (
<div className="border border-gb-bg-t bg-gb-bg rounded font-mono text-sm flex flex-col max-h-80 w-full">
{/* Header with search */}
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-gb-bg-t">
<span className="text-gb-fg-f select-none">{'>'} search:</span>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="_"
className="bg-transparent text-gb-fg outline-none flex-1 caret-gb-orange"
/>
<button
type="button"
onClick={onClose}
className="text-gb-fg-f hover:text-gb-orange select-none ml-1"
title="Close"
>
[x]
</button>
</div>
{/* Results grid */}
<div className="overflow-y-auto p-2">
{loading && (
<p className="text-gb-fg-f text-center py-2">[loading...]</p>
)}
{!loading && results.length === 0 && (
<p className="text-gb-fg-f text-center py-2">[no results]</p>
)}
{!loading && results.length > 0 && (
<div className="grid grid-cols-3 gap-1">
{results.map((gif) => (
<button
key={gif.id}
type="button"
onClick={() => handleClick(gif)}
className="cursor-pointer border border-transparent hover:border-gb-orange rounded overflow-hidden focus:outline-none focus:border-gb-orange"
>
<img
src={gif.images.fixed_height.url}
alt={gif.title || 'GIF'}
className="w-full h-auto object-cover"
loading="lazy"
/>
</button>
))}
</div>
)}
</div>
</div>
);
}
export type { Gif };
+272
View File
@@ -0,0 +1,272 @@
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth.ts';
export function UserSettings() {
const { user, updateProfile, isLoading, error, clearError, fetchMe } = useAuthStore();
const navigate = useNavigate();
const [displayName, setDisplayName] = useState('');
const [bio, setBio] = useState('');
const [statusText, setStatusText] = useState('');
const [accentColor, setAccentColor] = useState('#fe8019');
const [saved, setSaved] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (user) {
setDisplayName(user.display_name || '');
setBio(user.bio || '');
setStatusText(user.status_text || '');
setAccentColor(user.accent_color || '#fe8019');
}
}, [user]);
if (!user) {
navigate('/login');
return null;
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
setSaved(false);
try {
await updateProfile({
display_name: displayName,
bio,
status_text: statusText,
accent_color: accentColor,
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch {
// error is set in store
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setUploadError(null);
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/v1/upload', {
method: 'POST',
credentials: 'include',
body: formData,
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.message || data?.error || `Upload failed: ${response.status}`);
}
await fetchMe();
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Upload failed');
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const hexValid = /^#[0-9a-fA-F]{6}$/.test(accentColor);
return (
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
<div className="max-w-2xl mx-auto">
{/* Box-drawn frame */}
<div className="border border-gb-bg-t p-6">
{/* Header */}
<pre className="text-gb-orange font-mono text-center mb-6">
{'┌──────────────────────────────────┐\n'}
{'│ === PROFILE SETTINGS === │\n'}
{'└──────────────────────────────────┘'}
</pre>
<form onSubmit={handleSave} className="space-y-5">
{/* Avatar Section */}
<div className="border border-gb-bg-t p-4">
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
AVATAR:
</label>
<div className="flex items-center gap-4">
<div
className="w-16 h-16 rounded border-2 flex items-center justify-center overflow-hidden shrink-0"
style={{ borderColor: hexValid ? accentColor : '#504945' }}
>
{user.avatar ? (
<img
src={user.avatar}
alt="avatar"
className="w-full h-full object-cover"
/>
) : (
<span className="text-gb-fg-f text-2xl font-mono">
{user.username.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<span className="text-gb-fg-s text-sm font-mono truncate max-w-xs">
{user.avatar || 'no avatar set'}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="terminal-button text-xs"
disabled={uploading}
>
{uploading ? '[UPLOADING...]' : '[UPLOAD NEW]'}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
/>
</div>
{uploadError && (
<p className="text-gb-red text-xs font-mono">ERR: {uploadError}</p>
)}
</div>
</div>
</div>
{/* Display Name */}
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
DISPLAY_NAME:
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value.slice(0, 32))}
maxLength={32}
className="terminal-input w-full"
placeholder="your display name"
/>
<span className="text-gb-fg-f text-xs font-mono mt-1 block">
{displayName.length}/32
</span>
</div>
{/* Bio */}
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
BIO:
</label>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value.slice(0, 250))}
maxLength={250}
rows={4}
className="terminal-input w-full resize-none"
placeholder="tell the world about yourself..."
/>
<span className="text-gb-fg-f text-xs font-mono mt-1 block">
{bio.length}/250
</span>
</div>
{/* Status Text */}
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
STATUS_TEXT:
</label>
<input
type="text"
value={statusText}
onChange={(e) => setStatusText(e.target.value.slice(0, 128))}
maxLength={128}
className="terminal-input w-full"
placeholder="what are you up to?"
/>
<span className="text-gb-fg-f text-xs font-mono mt-1 block">
{statusText.length}/128
</span>
</div>
{/* Accent Color */}
<div>
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
ACCENT_COLOR:
</label>
<div className="flex items-center gap-3">
<input
type="text"
value={accentColor}
onChange={(e) => {
let val = e.target.value;
if (!val.startsWith('#')) val = '#' + val;
setAccentColor(val.slice(0, 7));
}}
maxLength={7}
className="terminal-input w-32"
placeholder="#fe8019"
/>
<div
className="w-8 h-8 border border-gb-bg-t rounded-sm shrink-0"
style={{ backgroundColor: hexValid ? accentColor : '#504945' }}
title={hexValid ? accentColor : 'invalid hex'}
/>
{!hexValid && accentColor.length > 0 && (
<span className="text-gb-red text-xs font-mono">
invalid hex
</span>
)}
</div>
</div>
{/* Errors & Success */}
{error && (
<p className="text-gb-red text-sm font-mono">ERR: {error}</p>
)}
{saved && (
<p className="text-gb-green text-sm font-mono animate-pulse">
[saved!]
</p>
)}
{/* Save Button */}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
className="terminal-button"
disabled={isLoading || !hexValid}
>
{isLoading ? '[SAVING...]' : '[SAVE]'}
</button>
<Link
to="/"
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
>
[CANCEL]
</Link>
</div>
</form>
{/* Footer info */}
<div className="mt-6 pt-4 border-t border-gb-bg-t">
<p className="text-gb-fg-f text-xs font-mono">
USER: {user.username} | ID: {user.id.slice(0, 8)}... | JOINED: {new Date(user.created_at).toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
);
}
+31 -5
View File
@@ -6,10 +6,21 @@ export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline';
export interface User {
id: string;
username: string;
displayName: string;
display_name: string;
email: string;
avatar: string | null;
avatar: string;
bio: string;
accent_color: string;
status: UserStatus;
status_text: string;
created_at: string;
}
export interface UpdateProfilePayload {
display_name?: string;
bio?: string;
accent_color?: string;
status_text?: string;
}
interface AuthState {
@@ -18,9 +29,10 @@ interface AuthState {
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, username: string, password: string) => Promise<void>;
register: (email: string, username: string, password: string, displayName?: string) => Promise<void>;
logout: () => Promise<void>;
fetchMe: () => Promise<void>;
updateProfile: (data: UpdateProfilePayload) => Promise<void>;
clearError: () => void;
}
@@ -45,10 +57,10 @@ export const useAuthStore = create<AuthState>((set) => ({
}
},
register: async (email, username, password) => {
register: async (email, username, password, displayName) => {
set({ isLoading: true, error: null });
try {
await api.post('/auth/register', { email, username, password });
await api.post('/auth/register', { email, username, password, display_name: displayName || username });
const user = await api.get<User>('/auth/me');
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
@@ -89,5 +101,19 @@ export const useAuthStore = create<AuthState>((set) => ({
}
},
updateProfile: async (data) => {
set({ isLoading: true, error: null });
try {
const user = await api.patch<User>('/auth/me', data);
set({ user, isLoading: false });
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Update failed',
});
throw error;
}
},
clearError: () => set({ error: null }),
}));