Files
dumpsterChat/web/src/components/UserSettings.tsx
T
hobokenchicken 4da08d91bc feat(ui): Discord-style roles/channel perms + IDE themes
Split-pane role editor with tri-state channel overrides (roles/members).
CSS-var themes (Gruvbox default + 9 IDE palettes) in top bar and settings.
2026-07-15 21:46:07 -04:00

454 lines
17 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth.ts';
import { usePushStore } from '../stores/push.ts';
import { ThemeToggle } from './ThemeToggle.tsx';
export function UserSettings() {
const { user, updateProfile, changePassword, isLoading, error, clearError } = useAuthStore();
const { isSupported, isSubscribed, subscribe, unsubscribe, checkSubscription } = usePushStore();
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);
// Password change state
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmNewPassword, setConfirmNewPassword] = useState('');
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [passwordSuccess, setPasswordSuccess] = useState(false);
useEffect(() => {
if (user) {
setDisplayName(user.display_name || '');
setBio(user.bio || '');
setStatusText(user.status_text || '');
setAccentColor(user.accent_color || '#fe8019');
}
checkSubscription();
}, [user, checkSubscription]);
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}`);
}
const uploadData = await response.json();
await updateProfile({ avatar_url: uploadData.url });
} 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);
const handleChangePassword = async () => {
setPasswordError(null);
setPasswordSuccess(false);
if (newPassword !== confirmNewPassword) {
setPasswordError("new passwords do not match");
return;
}
if (newPassword.length < 8) {
setPasswordError("password must be at least 8 characters");
return;
}
setPasswordLoading(true);
try {
await changePassword(currentPassword, newPassword);
setPasswordSuccess(true);
setCurrentPassword('');
setNewPassword('');
setConfirmNewPassword('');
setTimeout(() => setPasswordSuccess(false), 3000);
} catch {
// error is set in store
} finally {
setPasswordLoading(false);
}
};
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>
{/* Change Password */}
<div className="border border-gb-bg-t p-4">
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
CHANGE PASSWORD:
</label>
<div className="space-y-3">
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="terminal-input w-full"
placeholder="current password"
autoComplete="current-password"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="terminal-input w-full"
placeholder="new password (min 8 chars)"
autoComplete="new-password"
/>
<input
type="password"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
className="terminal-input w-full"
placeholder="confirm new password"
autoComplete="new-password"
/>
{passwordError && (
<p className="text-gb-red text-xs font-mono">ERR: {passwordError}</p>
)}
{passwordSuccess && (
<p className="text-gb-green text-xs font-mono">[password changed!]</p>
)}
<button
type="button"
onClick={handleChangePassword}
disabled={passwordLoading || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword}
className="terminal-button text-xs"
>
{passwordLoading ? '[CHANGING...]' : '[CHANGE PASSWORD]'}
</button>
</div>
</div>
{/* Appearance */}
<div className="border border-gb-bg-t p-4">
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
THEME:
</label>
<ThemeToggle />
<p className="text-gb-fg-f text-xs font-mono mt-2">
Default is Gruvbox. Choice is saved in this browser.
</p>
</div>
{/* Push Notifications */}
{isSupported && (
<div className="border border-gb-bg-t p-4">
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
NOTIFICATIONS:
</label>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<button
type="button"
onClick={async () => {
if (Notification.permission !== 'granted') {
await Notification.requestPermission();
// Force a re-render to update the permission status
setDisplayName(displayName + ' '); setTimeout(() => setDisplayName(displayName), 0);
}
}}
className={`terminal-button text-xs ${Notification.permission === 'granted' ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
>
{Notification.permission === 'granted' ? '[DESKTOP GRANTED]' : '[ENABLE DESKTOP]'}
</button>
<span className="text-gb-fg-f text-xs font-mono">
{Notification.permission === 'granted' ? '● allowed' : '○ not allowed'}
</span>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={isSubscribed ? unsubscribe : subscribe}
className={`terminal-button text-xs ${isSubscribed ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
>
{isSubscribed ? '[DISABLE PUSH]' : '[ENABLE PUSH]'}
</button>
<span className="text-gb-fg-f text-xs font-mono">
{isSubscribed ? '● subscribed' : '○ not subscribed'}
</span>
</div>
</div>
<p className="text-gb-fg-f text-xs font-mono mt-2">
Enable Desktop to get notifications while the app is running in the background. Enable Push to get notifications when the app is fully closed.
</p>
</div>
)}
{/* Passkey Registration */}
<div className="border border-gb-bg-t p-4">
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
PASSKEYS:
</label>
<button
type="button"
onClick={async () => {
try {
const resp = await fetch('/api/v1/auth/webauthn/register/begin', { method: 'POST', credentials: 'include' });
if (!resp.ok) throw new Error('Passkey registration not available');
const options = await resp.json();
const credential = await navigator.credentials.create({ publicKey: options });
if (!credential) return;
const finishResp = await fetch('/api/v1/auth/webauthn/register/finish', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
});
if (finishResp.ok) {
alert('Passkey registered successfully!');
}
} catch (err) {
console.error('Passkey registration failed:', err);
alert('Passkey registration not yet available on this server.');
}
}}
className="terminal-button text-xs border-gb-aqua text-gb-aqua"
>
[REGISTER PASSKEY]
</button>
<p className="text-gb-fg-f text-xs font-mono mt-2">
Use biometrics or security key for passwordless login.
</p>
</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>
);
}