evOo-local/apps/web/src/components/UserProfileSettings.tsx
cynarAI 3291a32d37
Show About you inside General settings (#165)
The profile had its own settings tab although it is a few rows of personal
details. It now sits as one card under Account in General, hides itself
while empty, and the separate tab is gone.


Claude-Session: https://claude.ai/code/session_017gHYZ4c9Twex73v4ZasmMJ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 18:36:54 +02:00

303 lines
9.4 KiB
TypeScript

import { Trans, useLingui } from "@lingui/react/macro";
import type { UserFactCategory, UserFactSource, UserProfileState } from "@rakazo/contracts";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
} from "@rakazo/ui-web";
import { PencilIcon, XIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useId, useState } from "react";
import { getActiveUiLocale } from "../lib/i18n";
import { rpc } from "../lib/rpc";
import type { ProfileField } from "../lib/user-profile-view";
import {
CLEARED_PROFILE_FIELD,
displayLanguageTag,
displayRegionTag,
factSpaceNames,
filledProfileFields,
groupUserFacts,
userFactEdit,
} from "../lib/user-profile-view";
import { UserFactEditor } from "./UserFactEditor";
function Row({
value,
detail,
actions,
testId,
status,
}: {
value: string;
detail: string;
actions: (describedBy: string) => ReactNode;
testId: string;
status?: string;
}) {
const valueId = useId();
return (
<li data-testid={testId} data-status={status} className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p id={valueId} className="text-ui-body break-words text-foreground/75">
{value}
</p>
<p className="text-ui-body-sm text-muted-foreground/70">{detail}</p>
</div>
<div className="flex shrink-0 items-center gap-1">{actions(valueId)}</div>
</li>
);
}
/** Everything the user's bots know about the user, with edit, consent and forget controls. */
export function UserProfileSettings() {
const { t } = useLingui();
const [state, setState] = useState<UserProfileState | null>(null);
const [spaces, setSpaces] = useState<Array<{ id: string; name: string }>>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [forgetOpen, setForgetOpen] = useState(false);
useEffect(() => {
let cancelled = false;
// Only names a fact's spaces; without them the profile still works.
rpc.spaces
.list()
.then((navigation) => {
if (!cancelled) setSpaces(navigation.spaces.map(({ id, name }) => ({ id, name })));
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
rpc.profile
.get()
.then((next) => {
if (!cancelled) setState(next);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : t`Couldn't load`);
});
return () => {
cancelled = true;
};
}, [t]);
async function apply(change: () => Promise<UserProfileState>): Promise<boolean> {
if (busy) return false;
setBusy(true);
setError(null);
try {
setState(await change());
return true;
} catch (err) {
setError(err instanceof Error ? err.message : t`Couldn't save`);
return false;
} finally {
setBusy(false);
}
}
const categoryLabels: Record<UserFactCategory, string> = {
identity: t`Identity`,
work: t`Work`,
goals: t`Goals`,
people: t`People`,
health: t`Health`,
values: t`Values`,
habits: t`Habits`,
preferences: t`Preferences`,
tools: t`Tools`,
};
const sourceLabels: Record<UserFactSource, string> = {
onboarding: t`Onboarding`,
user: t`You`,
conversation: t`Chat`,
consolidation: t`Chat history`,
connector: t`Connected app`,
};
const fieldLabels: Record<ProfileField, string> = {
timeZone: t`Time zone`,
language: t`Language`,
locale: t`Region`,
communicationStyle: t`Communication style`,
summary: t`Summary`,
};
const fields = state ? filledProfileFields(state.profile) : [];
const groups = state ? groupUserFacts(state.facts) : [];
const uiLocale = getActiveUiLocale();
const deleteButton = (describedBy: string, onClick: () => void) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Delete`}
aria-describedby={describedBy}
disabled={busy}
onClick={onClick}
>
<XIcon />
</Button>
);
if (!error && (!state || (fields.length === 0 && groups.length === 0))) return null;
return (
<section
data-testid="user-profile-settings"
className="space-y-4 rounded-xl border border-border px-4 py-4"
>
<h3 className="text-ui-body font-medium text-foreground">
<Trans>About you</Trans>
</h3>
{error ? (
<p role="alert" className="text-ui-body-sm text-destructive">
{error}
</p>
) : null}
{fields.length > 0 ? (
<ul className="space-y-3">
{fields.map(({ field, value }) => (
<Row
key={field}
testId={`profile-field-${field}`}
value={
field === "language"
? displayLanguageTag(value, uiLocale)
: field === "locale"
? displayRegionTag(value, uiLocale)
: value
}
detail={fieldLabels[field]}
actions={(describedBy) =>
deleteButton(
describedBy,
() => void apply(() => rpc.profile.update(CLEARED_PROFILE_FIELD[field])),
)
}
/>
))}
</ul>
) : null}
{groups.map((group) => (
<div key={group.category} data-testid={`user-facts-${group.category}`}>
<h4 className="text-ui-body-sm text-muted-foreground">
{categoryLabels[group.category]}
</h4>
<ul className="mt-2 space-y-3">
{group.facts.map((fact) =>
fact.id === editingId ? (
<UserFactEditor
key={fact.id}
fact={fact}
spaces={spaces}
categoryLabels={categoryLabels}
busy={busy}
onCancel={() => setEditingId(null)}
onSave={(draft) => {
const edit = userFactEdit(fact, draft);
if (!edit) {
setEditingId(null);
return;
}
void apply(() => rpc.profile.update({ editFacts: [edit] })).then((saved) => {
if (saved) setEditingId(null);
});
}}
/>
) : (
<Row
key={fact.id}
testId="user-fact"
status={fact.status}
value={fact.value}
detail={[
sourceLabels[fact.source],
factSpaceNames(fact.allSpaces ? null : fact.spaceIds, spaces).join(", "),
]
.filter(Boolean)
.join(" · ")}
actions={(describedBy) => (
<>
{fact.status === "pending" ? (
<Button
variant="outline"
size="sm"
aria-describedby={describedBy}
disabled={busy}
onClick={() =>
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
}
>
<Trans>Confirm</Trans>
</Button>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Edit`}
aria-describedby={describedBy}
disabled={busy}
onClick={() => {
setError(null);
setEditingId(fact.id);
}}
>
<PencilIcon />
</Button>
{deleteButton(
describedBy,
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
)}
</>
)}
/>
),
)}
</ul>
</div>
))}
{fields.length > 0 || groups.length > 0 ? (
<Button variant="destructive" disabled={busy} onClick={() => setForgetOpen(true)}>
<Trans>Forget everything</Trans>
</Button>
) : null}
<AlertDialog open={forgetOpen} onOpenChange={setForgetOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Forget everything about you?</Trans>
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={busy}
onClick={() => {
setForgetOpen(false);
void apply(() => rpc.profile.forget({ all: true }));
}}
>
<Trans>Forget</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}