e69553af02
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
273 lines
9.3 KiB
TypeScript
273 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|