Full presentation-layer rebuild of packages/web (no core/API/model changes). - Design system: indigo light+dark token sets, 25 inline SVG icons, CSS foundation (spacing/radius/type/motion scales, per-theme elevation, reset, scrollbars). - Shell: two-pane rail (brand, icon theme switch, live search, rich conversation rows, account footer, group/contacts compose) + auth card. - Chat pane: sticky header with presence/typing, grouped bubbles with date separators, icon read-ticks, hover toolbar, inline edit, reaction pills, auto-growing composer with Enter-to-send, near-bottom-aware autoscroll. - Media renderers: waveform voice player (WebAudio decode + pseudo-waveform fallback), circular video notes (rendered bare), image lightbox with zoom/pan, framed video->lightbox player, file cards. - Restore + modernize notification toast styles. Emoji kept only as reaction content; all chrome is icons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
106 lines
3.4 KiB
TypeScript
106 lines
3.4 KiB
TypeScript
import { useState } from 'react';
|
|
import type { ReactElement, SyntheticEvent } from 'react';
|
|
import { ApiError } from '@altricade/core/api';
|
|
import { useSession } from '../../../entities/session';
|
|
|
|
type Mode = 'login' | 'register';
|
|
|
|
export const AuthForm = (): ReactElement => {
|
|
const { login, register } = useSession();
|
|
const [mode, setMode] = useState<Mode>('login');
|
|
const [username, setUsername] = useState('');
|
|
const [displayName, setDisplayName] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const submit = async (event: SyntheticEvent): Promise<void> => {
|
|
event.preventDefault();
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
if (mode === 'login') {
|
|
await login({ username, password });
|
|
} else {
|
|
await register({ username, displayName, password });
|
|
}
|
|
} catch (caught) {
|
|
setError(caught instanceof ApiError ? caught.message : 'Something went wrong');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<main className="auth">
|
|
<div className="auth-card">
|
|
<div className="auth-brand">
|
|
<span className="auth-mark" aria-hidden="true">
|
|
A
|
|
</span>
|
|
<h1 className="auth-wordmark">Altricade</h1>
|
|
<p className="auth-tagline">
|
|
{mode === 'login' ? 'Welcome back.' : 'Create your account.'}
|
|
</p>
|
|
</div>
|
|
<form className="auth-form" onSubmit={(event) => void submit(event)}>
|
|
<label className="field-label">
|
|
Username
|
|
<input
|
|
className="field"
|
|
placeholder="username"
|
|
autoComplete="username"
|
|
value={username}
|
|
onChange={(event) => {
|
|
setUsername(event.target.value);
|
|
}}
|
|
/>
|
|
</label>
|
|
{mode === 'register' ? (
|
|
<label className="field-label">
|
|
Display name
|
|
<input
|
|
className="field"
|
|
placeholder="Your name"
|
|
value={displayName}
|
|
onChange={(event) => {
|
|
setDisplayName(event.target.value);
|
|
}}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
<label className="field-label">
|
|
Password
|
|
<input
|
|
className="field"
|
|
type="password"
|
|
placeholder="••••••••"
|
|
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
|
value={password}
|
|
onChange={(event) => {
|
|
setPassword(event.target.value);
|
|
}}
|
|
/>
|
|
</label>
|
|
{error !== null ? <p className="form-error">{error}</p> : null}
|
|
<button type="submit" className="btn-primary" disabled={busy}>
|
|
{busy ? 'Please wait…' : mode === 'login' ? 'Log in' : 'Create account'}
|
|
</button>
|
|
</form>
|
|
<p className="auth-switch">
|
|
{mode === 'login' ? "Don't have an account?" : 'Already have an account?'}{' '}
|
|
<button
|
|
type="button"
|
|
className="link"
|
|
onClick={() => {
|
|
setMode(mode === 'login' ? 'register' : 'login');
|
|
setError(null);
|
|
}}
|
|
>
|
|
{mode === 'login' ? 'Sign up' : 'Log in'}
|
|
</button>
|
|
</p>
|
|
</div>
|
|
</main>
|
|
);
|
|
};
|