Skip to content
File

Blob: src/client/pages/account/profile.tsx

typescript151 lines
1import { Check, Pencil, X } from "lucide-react";
2import { useState } from "react";
3 
4import { BackLink } from "@/client/components/ui/back-link";
5import { Button } from "@/client/components/ui/button";
6import { Card } from "@/client/components/ui/card";
7import { PageHeader } from "@/client/components/ui/page-header";
8import { Skeleton } from "@/client/components/ui/skeleton";
9import { useRequireSession } from "@/client/hooks/use-auth-guard";
10import { type SessionUser } from "@/client/lib/auth-client";
11import { fallbackHandle } from "@/client/lib/handle";
12import { apiJsonRequest, responseMessage } from "@/client/lib/http";
13 
14interface UsernameEditorProps {
15 initial: string;
16 onSaved: () => void | Promise<void>;
17}
18 
19// The page passes `key={currentHandle}` so a successful save (which
20// updates `currentHandle`) remounts this component, resetting the
21// editing/pending/error/value state without an effect-driven sync.
22const UsernameEditor = ({ initial, onSaved }: UsernameEditorProps) => {
23 const [editing, setEditing] = useState(false);
24 const [value, setValue] = useState(initial);
25 const [pending, setPending] = useState(false);
26 const [err, setErr] = useState<string | null>(null);
27 
28 const cancel = () => {
29 setValue(initial);
30 setErr(null);
31 setEditing(false);
32 };
33 
34 const save = async () => {
35 setErr(null);
36 setPending(true);
37 try {
38 const res = await apiJsonRequest("/api/account/handle", { preferredUsername: value }, { method: "POST" });
39 if (!res.ok) {
40 setErr(await responseMessage(res, "Could not update username."));
41 return;
42 }
43 await onSaved();
44 setEditing(false);
45 } finally {
46 setPending(false);
47 }
48 };
49 
50 return (
51 <Card>
52 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
53 <div className="min-w-0 flex-1">
54 <h2 className="text-lg font-semibold text-zinc-100">Username</h2>
55 <p className="mt-1 text-xs text-zinc-500">
56 Your <code className="font-mono">preferred_username</code> claim. Downstream apps may use it as a slug.
57 </p>
58 {editing && err ? <p className="mt-2 text-xs text-red-400">{err}</p> : null}
59 </div>
60 <div className="flex shrink-0 items-center gap-2">
61 {editing ? (
62 <>
63 <input
64 value={value}
65 onChange={(e) => setValue(e.target.value.toLowerCase())}
66 maxLength={32}
67 autoFocus
68 autoCapitalize="off"
69 autoCorrect="off"
70 spellCheck={false}
71 disabled={pending}
72 aria-label="Username"
73 className="h-9 w-48 rounded-lg border border-zinc-700/60 bg-zinc-800/80 px-3 font-mono text-sm text-zinc-100 focus:border-accent-500/50 focus:outline-none focus:ring-1 focus:ring-accent-500/30 disabled:cursor-not-allowed disabled:opacity-60"
74 onKeyDown={(e) => {
75 if (pending) return;
76 if (e.key === "Escape") cancel();
77 if (e.key === "Enter") void save();
78 }}
79 />
80 <Button size="sm" loading={pending} onClick={save} aria-label="Save username" className="!px-2">
81 {pending ? null : <Check className="h-4 w-4" aria-hidden="true" />}
82 </Button>
83 <Button
84 size="sm"
85 variant="ghost"
86 onClick={cancel}
87 disabled={pending}
88 aria-label="Cancel"
89 className="!px-2"
90 >
91 <X className="h-4 w-4" aria-hidden="true" />
92 </Button>
93 </>
94 ) : (
95 <>
96 <code className="inline-flex h-9 items-center rounded-lg border border-transparent bg-zinc-800/60 px-3 font-mono text-sm text-zinc-100">
97 {initial}
98 </code>
99 <Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
100 <Pencil className="h-3.5 w-3.5" aria-hidden="true" />
101 Edit
102 </Button>
103 </>
104 )}
105 </div>
106 </div>
107 </Card>
108 );
109};
110 
111const AccountProfilePageSkeleton = () => (
112 <div className="mx-auto max-w-3xl" aria-busy="true">
113 <Skeleton className="mb-6 h-4 w-24" />
114 <div className="mb-8">
115 <Skeleton className="mb-3 h-8 w-32" />
116 <Skeleton className="h-4 w-72" />
117 </div>
118 <div className="rounded-2xl border border-zinc-800/60 bg-zinc-900/40 p-5 sm:p-6">
119 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
120 <div className="min-w-0 flex-1 space-y-2">
121 <Skeleton className="h-5 w-28" />
122 <Skeleton className="h-3 w-72" />
123 </div>
124 <div className="flex shrink-0 items-center gap-2">
125 <Skeleton className="h-9 w-48" />
126 <Skeleton className="h-9 w-16" />
127 </div>
128 </div>
129 </div>
130 </div>
131);
132 
133export const AccountProfilePage = () => {
134 const session = useRequireSession("/account/profile");
135 
136 if (session.isPending || !session.data) {
137 return <AccountProfilePageSkeleton />;
138 }
139 
140 const sessionUser = session.data.user as SessionUser;
141 const currentHandle = sessionUser.preferredUsername ?? fallbackHandle(sessionUser);
142 
143 return (
144 <div className="mx-auto max-w-3xl">
145 <BackLink to="/account">Account</BackLink>
146 <PageHeader title="Profile" description={`Signed in as ${session.data.user.email}.`} />
147 <UsernameEditor key={currentHandle} initial={currentHandle} onSaved={() => session.refetch()} />
148 </div>
149 );
150};