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
+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 };