learning_ai_notes/web/src/app/(app)/settings/page.tsx
saravanakumardb1 30a30ceb0f feat(web/ui5): migrate settings page + 4 modals to @bytelyst/ui primitives
Completes the high-leverage half of UI5 by migrating the most form-heavy
authenticated screens off the legacy 'input-shell' / inline-style pattern
onto Input, Textarea, Select, and AlertBanner primitives.

Migrated:
- web/src/app/(app)/settings/page.tsx — change-password form, feedback
  form, MCP/API-tokens/offline-queue cards. Replaces 'surface-card'
  sections with Card components, 'input-shell' inputs/selects/textareas
  with Input/Select/Textarea, and inline error/success divs with
  AlertBanner.
- web/src/components/CreateNoteModal.tsx — template/workspace/title/body/tags
  fields. Select primitive uses options=[{value,label}].
- web/src/components/LinkNoteModal.tsx — search input + relationship-type
  select + alert banner for errors.
- web/src/components/ShareDialog.tsx — user-id input, permission select,
  collaborator/public-link rows now use AlertBanner (tone='neutral') for
  the muted-surface look. Web Share API unsupported message is now a
  proper tone='warning' banner.
- web/src/components/PromptTemplateEditor.tsx — full form (name, slug,
  description, 3 selects, 2 textareas) migrated.

All existing tests continue to pass without modification because
@testing-library queries (getByLabel, getByPlaceholder, getByText) are
robust against the underlying HTML structure changes.

Verified:
- pnpm --filter @notelett/web run typecheck: passes
- pnpm --filter @notelett/web run test: 96/96 (existing CreateNoteModal,
  LinkNoteModal, ShareDialog suites all green)
- pnpm run verify: end-to-end (backend 380/380, web 96/96, mobile 97/97)
- Legacy class matches in web/src dropped from 89 to 69 over the UI5
  slice; remaining matches are in UI6/UI7 territory (dashboard, search,
  workspaces list, notes detail, chat, palace, NoteEditor).
2026-05-23 00:05:49 -07:00

247 lines
8.8 KiB
TypeScript

"use client";
import { useState } from "react";
import { useTheme } from "@/lib/use-theme";
import { useAuth } from "@/lib/auth";
import { AppShell } from "@/components/AppShell";
import { AlertBanner, Button, Card, Input, Select, Textarea } from "@/components/ui/Primitives";
import { getFeedbackClient } from "@/lib/feedback-client";
import { toast } from "@/lib/toast";
import { NOTES_API_URL, PLATFORM_SERVICE_URL, MCP_SERVER_URL, PRODUCT_ID } from "@/lib/product-config";
import { getOfflineQueue } from "@/lib/offline-queue";
export default function SettingsPage() {
const { theme, toggle } = useTheme();
const { user, logout, changePassword, deleteAccount, isLoading, error, success, clearMessages } = useAuth();
const [feedbackTitle, setFeedbackTitle] = useState("");
const [feedbackBody, setFeedbackBody] = useState("");
const [feedbackType, setFeedbackType] = useState<"bug" | "feature" | "praise" | "other">("bug");
const [submittingFeedback, setSubmittingFeedback] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
async function handleSubmitFeedback() {
if (!feedbackTitle.trim()) return;
setSubmittingFeedback(true);
try {
await getFeedbackClient().submitWithScreenshot({
type: feedbackType,
title: feedbackTitle.trim(),
body: feedbackBody.trim() || undefined,
platform: "web",
});
toast.success("Feedback submitted");
setFeedbackTitle("");
setFeedbackBody("");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to submit feedback");
} finally {
setSubmittingFeedback(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
clearMessages();
const ok = await changePassword(currentPassword, newPassword);
if (ok) {
setCurrentPassword("");
setNewPassword("");
toast.success("Password changed");
}
}
async function handleDeleteAccount() {
const pw = prompt("Enter your password to confirm account deletion:");
if (!pw) return;
const ok = await deleteAccount(pw);
if (ok) toast.success("Account deleted");
}
return (
<AppShell
title="Settings"
description="Account, preferences, feedback, and session management."
actions={
<Button
onClick={logout}
variant="secondary"
size="sm"
className="border-transparent bg-[var(--nl-danger-muted)] text-[var(--nl-danger)] hover:bg-[var(--nl-danger-muted)]"
>
Sign out
</Button>
}
>
<section className="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
{/* Profile */}
<Card padding="md" className="grid gap-3">
<strong>Profile</strong>
<div className="text-[color:var(--nl-text-secondary)]">
{user?.name ?? "—"} &middot; {user?.email ?? "—"} &middot; {user?.role ?? "—"}
</div>
</Card>
{/* Appearance */}
<Card padding="md" className="flex items-center justify-between">
<div className="grid gap-2">
<strong>Appearance</strong>
<div className="text-[color:var(--nl-text-secondary)]">Switch between dark and light mode</div>
</div>
<Button
onClick={toggle}
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
variant="secondary"
size="sm"
>
{theme === "dark" ? "Light" : "Dark"}
</Button>
</Card>
{/* Change password */}
<Card padding="md" className="grid gap-3">
<strong>Change password</strong>
{error && (
<AlertBanner tone="error" role="alert">
{error}
</AlertBanner>
)}
{success && (
<AlertBanner tone="success" role="status">
{success}
</AlertBanner>
)}
<form onSubmit={handleChangePassword} className="grid gap-3">
<Input
type="password"
placeholder="Current password"
aria-label="Current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
<Input
type="password"
placeholder="New password"
aria-label="New password"
minLength={8}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
<Button type="submit" disabled={isLoading} loading={isLoading} className="justify-self-start">
Update password
</Button>
</form>
</Card>
{/* Danger zone */}
<Card padding="md" className="grid gap-3">
<strong>Danger zone</strong>
<Button
onClick={handleDeleteAccount}
variant="secondary"
className="justify-self-start border-transparent bg-[var(--nl-danger-muted)] text-[var(--nl-danger)] hover:bg-[var(--nl-danger-muted)]"
>
Delete account
</Button>
</Card>
</section>
<Card padding="md" className="mt-4 grid gap-3">
<strong>Connect your agent (MCP)</strong>
<p className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
Use your platform access token with the shared MCP server on port 4007. Point tools at the NoteLett backend and product id{" "}
<code>{PRODUCT_ID}</code>.
</p>
<pre className="m-0 overflow-auto whitespace-pre-wrap rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-3 text-[length:var(--nl-fs-sm)]">
{`Notes API base: ${NOTES_API_URL}
Platform API: ${PLATFORM_SERVICE_URL}
MCP API base: ${MCP_SERVER_URL}
# Example Cursor / Claude MCP entry (adjust to your installer):
# "mcpServers": {
# "notelett": { "url": "${MCP_SERVER_URL}" }
# }`}
</pre>
<p className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
Deep links for tools: see <code>docs/DEEP_LINKS.md</code> in the repo.
</p>
</Card>
<Card padding="md" className="mt-4 grid gap-3">
<strong>API tokens for automation</strong>
<p className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
Scoped tokens for MCP or CI are provisioned through the ByteLyst platform when your tenant enables them. This web app does not yet expose
create/revoke; use the platform admin or CLI when available.
</p>
</Card>
<Card padding="md" className="mt-4 grid gap-3">
<strong>Offline queue</strong>
<p className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
Failed writes are retried from local storage via <code>@bytelyst/offline-queue</code> (storage key{" "}
<code>{`${PRODUCT_ID}_offline_queue`}</code>). Reload or return online to flush.
</p>
<Button
type="button"
variant="secondary"
className="justify-self-start"
onClick={() => {
try {
getOfflineQueue();
toast.success("Offline queue is available in this build");
} catch {
toast.error("Offline queue unavailable");
}
}}
>
Verify offline queue
</Button>
</Card>
{/* Feedback */}
<Card padding="md" className="mt-4 grid gap-3">
<strong>Send feedback</strong>
<div className="grid gap-3 [grid-template-columns:140px_1fr]">
<Select
value={feedbackType}
onChange={(e) => setFeedbackType(e.target.value as typeof feedbackType)}
aria-label="Feedback type"
options={[
{ value: "bug", label: "Bug" },
{ value: "feature", label: "Feature request" },
{ value: "praise", label: "Praise" },
{ value: "other", label: "Other" },
]}
/>
<Input
placeholder="Title"
aria-label="Feedback title"
value={feedbackTitle}
onChange={(e) => setFeedbackTitle(e.target.value)}
/>
</div>
<Textarea
placeholder="Details (optional)"
aria-label="Feedback details"
rows={3}
value={feedbackBody}
onChange={(e) => setFeedbackBody(e.target.value)}
className="resize-y"
/>
<Button
onClick={handleSubmitFeedback}
disabled={submittingFeedback || !feedbackTitle.trim()}
loading={submittingFeedback}
className="justify-self-start"
>
Send feedback
</Button>
</Card>
</AppShell>
);
}