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:
+20
-1
@@ -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={
|
||||
|
||||
@@ -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 = ``;
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user