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