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