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).
This commit is contained in:
saravanakumardb1 2026-05-23 00:05:49 -07:00
parent a83e60a60a
commit 30a30ceb0f
5 changed files with 274 additions and 280 deletions

View File

@ -4,7 +4,7 @@ import { useState } from "react";
import { useTheme } from "@/lib/use-theme"; import { useTheme } from "@/lib/use-theme";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Button, Card } from "@/components/ui/Primitives"; import { AlertBanner, Button, Card, Input, Select, Textarea } from "@/components/ui/Primitives";
import { getFeedbackClient } from "@/lib/feedback-client"; import { getFeedbackClient } from "@/lib/feedback-client";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { NOTES_API_URL, PLATFORM_SERVICE_URL, MCP_SERVER_URL, PRODUCT_ID } from "@/lib/product-config"; import { NOTES_API_URL, PLATFORM_SERVICE_URL, MCP_SERVER_URL, PRODUCT_ID } from "@/lib/product-config";
@ -65,25 +65,30 @@ export default function SettingsPage() {
title="Settings" title="Settings"
description="Account, preferences, feedback, and session management." description="Account, preferences, feedback, and session management."
actions={ actions={
<Button onClick={logout} variant="secondary" size="sm" className="bg-[var(--nl-danger-muted)] text-[var(--nl-danger)] border-transparent hover:bg-[var(--nl-danger-muted)]"> <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 Sign out
</Button> </Button>
} }
> >
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))", gap: "var(--nl-space-4)" }}> <section className="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
{/* Profile */} {/* Profile */}
<Card style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}> <Card padding="md" className="grid gap-3">
<strong>Profile</strong> <strong>Profile</strong>
<div style={{ color: "var(--nl-text-secondary)" }}> <div className="text-[color:var(--nl-text-secondary)]">
{user?.name ?? "—"} &middot; {user?.email ?? "—"} &middot; {user?.role ?? "—"} {user?.name ?? "—"} &middot; {user?.email ?? "—"} &middot; {user?.role ?? "—"}
</div> </div>
</Card> </Card>
{/* Appearance */} {/* Appearance */}
<Card style={{ padding: "var(--nl-space-5)", display: "flex", justifyContent: "space-between", alignItems: "center" }}> <Card padding="md" className="flex items-center justify-between">
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}> <div className="grid gap-2">
<strong>Appearance</strong> <strong>Appearance</strong>
<div style={{ color: "var(--nl-text-secondary)" }}>Switch between dark and light mode</div> <div className="text-[color:var(--nl-text-secondary)]">Switch between dark and light mode</div>
</div> </div>
<Button <Button
onClick={toggle} onClick={toggle}
@ -96,38 +101,62 @@ export default function SettingsPage() {
</Card> </Card>
{/* Change password */} {/* Change password */}
<Card style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}> <Card padding="md" className="grid gap-3">
<strong>Change password</strong> <strong>Change password</strong>
{error && <div style={{ color: "var(--nl-danger)", fontSize: "var(--nl-fs-sm)" }}>{error}</div>} {error && (
{success && <div style={{ color: "var(--nl-status-success)", fontSize: "var(--nl-fs-sm)" }}>{success}</div>} <AlertBanner tone="error" role="alert">
<form onSubmit={handleChangePassword} style={{ display: "grid", gap: "var(--nl-space-3)" }}> {error}
<input className="input-shell" type="password" placeholder="Current password" aria-label="Current password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required /> </AlertBanner>
<input className="input-shell" 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} style={{ justifySelf: "start" }}> {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 Update password
</Button> </Button>
</form> </form>
</Card> </Card>
{/* Danger zone */} {/* Danger zone */}
<Card style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}> <Card padding="md" className="grid gap-3">
<strong>Danger zone</strong> <strong>Danger zone</strong>
<Button onClick={handleDeleteAccount} variant="secondary" className="bg-[var(--nl-danger-muted)] text-[var(--nl-danger)] border-transparent hover:bg-[var(--nl-danger-muted)]" style={{ justifySelf: "start" }}> <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 Delete account
</Button> </Button>
</Card> </Card>
</section> </section>
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}> <Card padding="md" className="mt-4 grid gap-3">
<strong>Connect your agent (MCP)</strong> <strong>Connect your agent (MCP)</strong>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}> <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{" "} 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>. <code>{PRODUCT_ID}</code>.
</p> </p>
<pre <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)]">
className="surface-muted"
style={{ margin: 0, padding: "var(--nl-space-3)", fontSize: "var(--nl-fs-sm)", overflow: "auto", whiteSpace: "pre-wrap" }}
>
{`Notes API base: ${NOTES_API_URL} {`Notes API base: ${NOTES_API_URL}
Platform API: ${PLATFORM_SERVICE_URL} Platform API: ${PLATFORM_SERVICE_URL}
MCP API base: ${MCP_SERVER_URL} MCP API base: ${MCP_SERVER_URL}
@ -137,29 +166,29 @@ MCP API base: ${MCP_SERVER_URL}
# "notelett": { "url": "${MCP_SERVER_URL}" } # "notelett": { "url": "${MCP_SERVER_URL}" }
# }`} # }`}
</pre> </pre>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}> <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. Deep links for tools: see <code>docs/DEEP_LINKS.md</code> in the repo.
</p> </p>
</section> </Card>
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}> <Card padding="md" className="mt-4 grid gap-3">
<strong>API tokens for automation</strong> <strong>API tokens for automation</strong>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}> <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 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. create/revoke; use the platform admin or CLI when available.
</p> </p>
</section> </Card>
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}> <Card padding="md" className="mt-4 grid gap-3">
<strong>Offline queue</strong> <strong>Offline queue</strong>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}> <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{" "} 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. <code>{`${PRODUCT_ID}_offline_queue`}</code>). Reload or return online to flush.
</p> </p>
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
style={{ justifySelf: "start" }} className="justify-self-start"
onClick={() => { onClick={() => {
try { try {
getOfflineQueue(); getOfflineQueue();
@ -171,30 +200,47 @@ MCP API base: ${MCP_SERVER_URL}
> >
Verify offline queue Verify offline queue
</Button> </Button>
</section> </Card>
{/* Feedback */} {/* Feedback */}
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}> <Card padding="md" className="mt-4 grid gap-3">
<strong>Send feedback</strong> <strong>Send feedback</strong>
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr", gap: "var(--nl-space-3)" }}> <div className="grid gap-3 [grid-template-columns:140px_1fr]">
<select className="input-shell" value={feedbackType} onChange={(e) => setFeedbackType(e.target.value as typeof feedbackType)} aria-label="Feedback type"> <Select
<option value="bug">Bug</option> value={feedbackType}
<option value="feature">Feature request</option> onChange={(e) => setFeedbackType(e.target.value as typeof feedbackType)}
<option value="praise">Praise</option> aria-label="Feedback type"
<option value="other">Other</option> options={[
</select> { value: "bug", label: "Bug" },
<input className="input-shell" placeholder="Title" aria-label="Feedback title" value={feedbackTitle} onChange={(e) => setFeedbackTitle(e.target.value)} /> { 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> </div>
<textarea className="input-shell" placeholder="Details (optional)" aria-label="Feedback details" rows={3} value={feedbackBody} onChange={(e) => setFeedbackBody(e.target.value)} style={{ resize: "vertical" }} /> <Textarea
placeholder="Details (optional)"
aria-label="Feedback details"
rows={3}
value={feedbackBody}
onChange={(e) => setFeedbackBody(e.target.value)}
className="resize-y"
/>
<Button <Button
onClick={handleSubmitFeedback} onClick={handleSubmitFeedback}
disabled={submittingFeedback || !feedbackTitle.trim()} disabled={submittingFeedback || !feedbackTitle.trim()}
loading={submittingFeedback} loading={submittingFeedback}
style={{ justifySelf: "start" }} className="justify-self-start"
> >
Send feedback Send feedback
</Button> </Button>
</section> </Card>
</AppShell> </AppShell>
); );
} }

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Button, Card } from "@/components/ui/Primitives"; import { AlertBanner, Button, Card, Input, Select, Textarea } from "@/components/ui/Primitives";
import { createNote } from "@/lib/notes-client"; import { createNote } from "@/lib/notes-client";
import { NOTE_TEMPLATES } from "@/lib/note-templates"; import { NOTE_TEMPLATES } from "@/lib/note-templates";
import type { WorkspaceSummary } from "@/lib/types"; import type { WorkspaceSummary } from "@/lib/types";
@ -62,39 +62,23 @@ export function CreateNoteModal({ workspaces, defaultWorkspaceId, onCreated, onC
return ( return (
<div <div
className="modal-overlay" className="modal-overlay fixed inset-0 z-[1000] flex items-center justify-center bg-[color:var(--nl-overlay-scrim)]"
style={{
position: "fixed",
inset: 0,
background: "var(--nl-overlay-scrim)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
> >
<Card <Card padding="lg" className="w-full max-w-[520px]">
padding="lg" <form onSubmit={handleSubmit} className="grid gap-4">
style={{ width: "100%", maxWidth: 520 }} <div className="text-[length:var(--nl-fs-xl)] font-bold">Create Note</div>
>
<form
onSubmit={handleSubmit}
style={{
display: "grid",
gap: "var(--nl-space-4)",
}}
>
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>Create Note</div>
{error && <div style={{ color: "var(--nl-danger)", fontSize: "0.875rem" }}>{error}</div>} {error && (
<AlertBanner tone="error" role="alert">
{error}
</AlertBanner>
)}
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> <Select
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Template</span> label="Template"
<select
className="input"
aria-label="Note template" aria-label="Note template"
value={templateId} value={templateId}
onChange={(e) => { onChange={(e) => {
@ -105,31 +89,22 @@ export function CreateNoteModal({ workspaces, defaultWorkspaceId, onCreated, onC
} }
applyTemplate(v); applyTemplate(v);
}} }}
> options={[
<option value="">Blank</option> { value: "", label: "Blank" },
{NOTE_TEMPLATES.map((t) => ( ...NOTE_TEMPLATES.map((t) => ({ value: t.id, label: t.label })),
<option key={t.id} value={t.id}> ]}
{t.label} />
</option>
))}
</select>
</label>
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> <Select
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Workspace</span> label="Workspace"
<select value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} className="input" aria-label="Workspace"> aria-label="Workspace"
{workspaces.map((ws) => ( value={workspaceId}
<option key={ws.id} value={ws.id}> onChange={(e) => setWorkspaceId(e.target.value)}
{ws.name} options={workspaces.map((ws) => ({ value: ws.id, label: ws.name }))}
</option> />
))}
</select>
</label>
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> <Input
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Title</span> label="Title"
<input
className="input"
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
@ -137,34 +112,27 @@ export function CreateNoteModal({ workspaces, defaultWorkspaceId, onCreated, onC
aria-label="Note title" aria-label="Note title"
autoFocus autoFocus
/> />
</label>
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> <Textarea
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Body</span> label="Body"
<textarea
className="input"
value={body} value={body}
onChange={(e) => setBody(e.target.value)} onChange={(e) => setBody(e.target.value)}
placeholder="Note content..." placeholder="Note content..."
aria-label="Note body" aria-label="Note body"
rows={6} rows={6}
style={{ resize: "vertical" }} className="resize-y"
/> />
</label>
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> <Input
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Tags (comma-separated)</span> label="Tags (comma-separated)"
<input
className="input"
type="text" type="text"
value={tags} value={tags}
onChange={(e) => setTags(e.target.value)} onChange={(e) => setTags(e.target.value)}
placeholder="launch, meeting, review" placeholder="launch, meeting, review"
aria-label="Note tags" aria-label="Note tags"
/> />
</label>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-3)" }}> <div className="flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={onClose}> <Button type="button" variant="secondary" onClick={onClose}>
Cancel Cancel
</Button> </Button>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Button, Card } from "@/components/ui/Primitives"; import { AlertBanner, Button, Card, Input, Select } from "@/components/ui/Primitives";
import { searchNoteSummaries, createNoteRelationship } from "@/lib/notes-client"; import { searchNoteSummaries, createNoteRelationship } from "@/lib/notes-client";
import type { NoteSummary } from "@/lib/types"; import type { NoteSummary } from "@/lib/types";
@ -63,42 +63,28 @@ export function LinkNoteModal({ noteId, workspaceId, existingLinkedIds, onLinked
return ( return (
<div <div
className="modal-overlay" className="modal-overlay fixed inset-0 z-[1000] flex items-center justify-center bg-[color:var(--nl-overlay-scrim)]"
style={{
position: "fixed",
inset: 0,
background: "var(--nl-overlay-scrim)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
> >
<Card <Card padding="lg" className="grid w-full max-w-[520px] gap-4">
padding="lg" <div className="text-[length:var(--nl-fs-xl)] font-bold">Link Note</div>
style={{
width: "100%",
maxWidth: 520,
display: "grid",
gap: "var(--nl-space-4)",
}}
>
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>Link Note</div>
{error && <div style={{ color: "var(--nl-danger)", fontSize: "0.875rem" }}>{error}</div>} {error && (
<AlertBanner tone="error" role="alert">
{error}
</AlertBanner>
)}
<form onSubmit={handleSearch} style={{ display: "flex", gap: "var(--nl-space-2)" }}> <form onSubmit={handleSearch} className="flex gap-2">
<input <Input
className="input"
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search notes..." placeholder="Search notes..."
aria-label="Search notes to link" aria-label="Search notes to link"
style={{ flex: 1 }} className="flex-1"
autoFocus autoFocus
/> />
<Button type="submit" variant="secondary"> <Button type="submit" variant="secondary">
@ -107,48 +93,42 @@ export function LinkNoteModal({ noteId, workspaceId, existingLinkedIds, onLinked
</form> </form>
{searched && results.length === 0 && ( {searched && results.length === 0 && (
<div style={{ color: "var(--nl-text-secondary)", fontSize: "0.875rem" }}>No matching notes found.</div> <div className="text-[length:0.875rem] text-[color:var(--nl-text-secondary)]">No matching notes found.</div>
)} )}
{results.length > 0 && ( {results.length > 0 && (
<div style={{ maxHeight: 200, overflowY: "auto", display: "grid", gap: "var(--nl-space-2)" }}> <div className="grid max-h-[200px] gap-2 overflow-y-auto">
{results.map((note) => ( {results.map((note) => (
<Button <Button
key={note.id} key={note.id}
type="button" type="button"
variant="secondary" variant="secondary"
className="flex-col items-start" className={`h-auto items-start justify-start text-left p-3 flex-col ${
style={{ selectedId === note.id
padding: "var(--nl-space-3)", ? "border-2 border-[color:var(--nl-accent-primary)]"
textAlign: "left", : "border-2 border-transparent"
border: selectedId === note.id ? "2px solid var(--nl-accent-primary)" : "2px solid transparent", }`}
justifyContent: "start",
height: "auto",
}}
onClick={() => setSelectedId(note.id)} onClick={() => setSelectedId(note.id)}
aria-label={`Select note: ${note.title}`} aria-label={`Select note: ${note.title}`}
> >
<div style={{ fontWeight: 600 }}>{note.title}</div> <div className="font-semibold">{note.title}</div>
<div style={{ color: "var(--nl-text-secondary)", fontSize: "0.875rem" }}>{note.excerpt}</div> <div className="text-[length:0.875rem] text-[color:var(--nl-text-secondary)]">{note.excerpt}</div>
</Button> </Button>
))} ))}
</div> </div>
)} )}
{selectedId && ( {selectedId && (
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> <Select
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Relationship type</span> label="Relationship type"
<select value={relationshipType} onChange={(e) => setRelationshipType(e.target.value)} className="input" aria-label="Relationship type"> aria-label="Relationship type"
{RELATIONSHIP_TYPES.map((rt) => ( value={relationshipType}
<option key={rt} value={rt}> onChange={(e) => setRelationshipType(e.target.value)}
{rt.replace("_", " ")} options={RELATIONSHIP_TYPES.map((rt) => ({ value: rt, label: rt.replace("_", " ") }))}
</option> />
))}
</select>
</label>
)} )}
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-3)" }}> <div className="flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={onClose}> <Button type="button" variant="secondary" onClick={onClose}>
Cancel Cancel
</Button> </Button>

View File

@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { Save, X } from "lucide-react"; import { Save, X } from "lucide-react";
import { Button, Card } from "@/components/ui/Primitives"; import { Button, Card, Input, Select, Textarea } from "@/components/ui/Primitives";
import { createPromptTemplate } from "@/lib/prompt-client"; import { createPromptTemplate } from "@/lib/prompt-client";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import type { PromptCategory } from "@/lib/types"; import type { PromptCategory } from "@/lib/types";
@ -63,78 +63,98 @@ export function PromptTemplateEditor({ onClose, onCreated }: PromptTemplateEdito
return ( return (
<div <div
style={{ position: "fixed", inset: 0, zIndex: 100, display: "grid", placeItems: "center", backgroundColor: "var(--nl-overlay-scrim)" }} className="fixed inset-0 z-[100] grid place-items-center bg-[color:var(--nl-overlay-scrim)]"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Create custom prompt template" aria-label="Create custom prompt template"
> >
<Card <Card
padding="lg" padding="lg"
style={{ width: "min(90vw, 600px)", maxHeight: "90vh", overflowY: "auto", padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }} className="grid max-h-[90vh] w-[min(90vw,600px)] gap-4 overflow-y-auto p-6"
> >
{/* Header */} {/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div className="flex items-center justify-between">
<strong>Create Custom Prompt</strong> <strong>Create Custom Prompt</strong>
<Button variant="secondary" size="sm" onClick={onClose} aria-label="Close editor" style={{ padding: 4 }}> <Button variant="secondary" size="sm" onClick={onClose} aria-label="Close editor" className="p-1">
<X size={16} /> <X size={16} />
</Button> </Button>
</div> </div>
{/* Name + slug */} {/* Name + slug */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--nl-space-3)" }}> <div className="grid grid-cols-2 gap-3">
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> <Input
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Name</span> label="Name"
<input className="input" value={name} onChange={(e) => autoSlug(e.target.value)} placeholder="My Action" aria-label="Template name" /> value={name}
</label> onChange={(e) => autoSlug(e.target.value)}
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> placeholder="My Action"
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Slug</span> aria-label="Template name"
<input className="input" value={slug} onChange={(e) => setSlug(e.target.value)} placeholder="my-action" aria-label="Template slug" /> />
</label> <Input
label="Slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="my-action"
aria-label="Template slug"
/>
</div> </div>
{/* Description */} {/* Description */}
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> <Input
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Description</span> label="Description"
<input className="input" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What this action does" aria-label="Description" /> value={description}
</label> onChange={(e) => setDescription(e.target.value)}
placeholder="What this action does"
aria-label="Description"
/>
{/* Category + input type + output type */} {/* Category + input type + output type */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "var(--nl-space-3)" }}> <div className="grid grid-cols-3 gap-3">
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> <Select
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Category</span> label="Category"
<select className="input" value={category} onChange={(e) => setCategory(e.target.value as PromptCategory)} aria-label="Category"> value={category}
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)} onChange={(e) => setCategory(e.target.value as PromptCategory)}
</select> aria-label="Category"
</label> options={CATEGORIES.map((c) => ({ value: c, label: c }))}
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> />
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Input type</span> <Select
<select className="input" value={inputType} onChange={(e) => setInputType(e.target.value as (typeof INPUT_TYPES)[number])} aria-label="Input type"> label="Input type"
{INPUT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} value={inputType}
</select> onChange={(e) => setInputType(e.target.value as (typeof INPUT_TYPES)[number])}
</label> aria-label="Input type"
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> options={INPUT_TYPES.map((t) => ({ value: t, label: t }))}
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Output</span> />
<select className="input" value={outputType} onChange={(e) => setOutputType(e.target.value as (typeof OUTPUT_TYPES)[number])} aria-label="Output type"> <Select
{OUTPUT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} label="Output"
</select> value={outputType}
</label> onChange={(e) => setOutputType(e.target.value as (typeof OUTPUT_TYPES)[number])}
aria-label="Output type"
options={OUTPUT_TYPES.map((t) => ({ value: t, label: t }))}
/>
</div> </div>
{/* System prompt */} {/* System prompt */}
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> <Textarea
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>System prompt</span> label="System prompt"
<textarea className="input" rows={4} value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} placeholder="You are a helpful assistant that..." aria-label="System prompt" /> rows={4}
</label> value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder="You are a helpful assistant that..."
aria-label="System prompt"
/>
{/* User prompt template */} {/* User prompt template */}
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}> <Textarea
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>User prompt template</span> label="User prompt template"
<textarea className="input" rows={4} value={userPromptTemplate} onChange={(e) => setUserPromptTemplate(e.target.value)} placeholder="{{noteBody}}" aria-label="User prompt template" /> rows={4}
<span style={{ fontSize: "var(--nl-fs-xs)", color: "var(--nl-text-secondary)" }}> value={userPromptTemplate}
Variables: {"{{note.title}}"}, {"{{note.body}}"}, {"{{note.tags}}"}, {"{{params.X}}"} onChange={(e) => setUserPromptTemplate(e.target.value)}
</span> placeholder="{{noteBody}}"
</label> aria-label="User prompt template"
hint={`Variables: {{note.title}}, {{note.body}}, {{note.tags}}, {{params.X}}`}
/>
{/* Save */} {/* Save */}
<Button <Button
@ -142,7 +162,7 @@ export function PromptTemplateEditor({ onClose, onCreated }: PromptTemplateEdito
loading={saving} loading={saving}
onClick={() => void handleSave()} onClick={() => void handleSave()}
aria-label={saving ? "Saving..." : "Create template"} aria-label={saving ? "Saving..." : "Create template"}
style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "var(--nl-space-2)" }} className="flex items-center justify-center gap-2"
> >
{!saving ? <Save size={16} /> : null} Create Template {!saving ? <Save size={16} /> : null} Create Template
</Button> </Button>

View File

@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { Button, Card } from "@/components/ui/Primitives"; import { AlertBanner, Button, Card, Input, Select } from "@/components/ui/Primitives";
import { createNoteShare, listNoteShares, revokeNoteShare, type PublicNoteShare } from "@/lib/notes-client"; import { createNoteShare, listNoteShares, revokeNoteShare, type PublicNoteShare } from "@/lib/notes-client";
import { exportNoteText, listCollaborators, removeCollaborator, shareNoteWithUser } from "@/lib/intake-client"; import { exportNoteText, listCollaborators, removeCollaborator, shareNoteWithUser } from "@/lib/intake-client";
import { getWebAppOrigin } from "@/lib/product-config"; import { getWebAppOrigin } from "@/lib/product-config";
@ -145,44 +145,29 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
return ( return (
<div <div
style={{ className="fixed inset-0 z-[1000] flex items-center justify-center bg-[color:var(--nl-overlay-scrim)]"
position: "fixed", onClick={(e) => {
inset: 0, if (e.target === e.currentTarget) onClose();
background: "var(--nl-overlay-scrim)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}} }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog" role="dialog"
aria-label="Share note" aria-label="Share note"
> >
<Card <Card padding="lg" className="grid w-[min(480px,90vw)] gap-4">
padding="lg" <div className="flex items-center justify-between">
style={{ <h2 className="m-0 text-[length:var(--nl-fs-xl)]">Share Note</h2>
width: "min(480px, 90vw)",
display: "grid",
gap: "var(--nl-space-4)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h2 style={{ margin: 0, fontSize: "var(--nl-fs-xl)" }}>Share Note</h2>
<Button <Button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close share dialog" aria-label="Close share dialog"
variant="ghost" variant="ghost"
size="sm" size="sm"
style={{ className="text-[length:var(--nl-fs-xl)]"
fontSize: "var(--nl-fs-xl)",
}}
> >
× ×
</Button> </Button>
</div> </div>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}> <div className="flex flex-wrap gap-2">
{tabs.map((t) => ( {tabs.map((t) => (
<Button <Button
key={t.key} key={t.key}
@ -198,28 +183,32 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
</div> </div>
{tab === "link" && ( {tab === "link" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}> <div className="grid gap-3">
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}> <p className="m-0 text-[color:var(--nl-text-secondary)]">
Generate a public read-only link that expires in 30 days. Revoke links you no longer want available. Generate a public read-only link that expires in 30 days. Revoke links you no longer want available.
</p> </p>
<Button type="button" disabled={loading} loading={loading} onClick={() => void handleCopyLink()} aria-label="Copy share link"> <Button type="button" disabled={loading} loading={loading} onClick={() => void handleCopyLink()} aria-label="Copy share link">
Create and Copy Link Create and Copy Link
</Button> </Button>
{publicLinks.length === 0 ? ( {publicLinks.length === 0 ? (
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}> <p className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
No active public links. No active public links.
</p> </p>
) : ( ) : (
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}> <div className="grid gap-2">
{publicLinks.map((link) => ( {publicLinks.map((link) => (
<div key={link.id} className="surface-muted" style={{ padding: "var(--nl-space-3)", display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap", alignItems: "center" }}> <AlertBanner
<span style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}> key={link.id}
tone="neutral"
className="flex flex-wrap items-center justify-between gap-3"
>
<span className="text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
Expires {link.expiresAt ? new Date(link.expiresAt).toLocaleDateString() : "when revoked"} Expires {link.expiresAt ? new Date(link.expiresAt).toLocaleDateString() : "when revoked"}
</span> </span>
<Button type="button" variant="secondary" size="sm" disabled={loading} onClick={() => void handleRevokeLink(link.shareToken)}> <Button type="button" variant="secondary" size="sm" disabled={loading} onClick={() => void handleRevokeLink(link.shareToken)}>
Revoke Revoke
</Button> </Button>
</div> </AlertBanner>
))} ))}
</div> </div>
)} )}
@ -227,52 +216,43 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
)} )}
{tab === "user" && ( {tab === "user" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}> <div className="grid gap-3">
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}> <p className="m-0 text-[color:var(--nl-text-secondary)]">
Share directly with a NoteLett user by their ID. Share directly with a NoteLett user by their ID.
</p> </p>
<input <Input
type="text" type="text"
value={userId} value={userId}
onChange={(e) => setUserId(e.target.value)} onChange={(e) => setUserId(e.target.value)}
placeholder="User ID" placeholder="User ID"
aria-label="User ID to share with" aria-label="User ID to share with"
style={{
padding: "var(--nl-space-3)",
border: "1px solid var(--nl-border-default)",
borderRadius: "var(--nl-radius-md)",
background: "var(--nl-input-bg)",
color: "var(--nl-text-primary)",
}}
/> />
<select <Select
value={permission} value={permission}
onChange={(e) => setPermission(e.target.value as "view" | "comment" | "edit")} onChange={(e) => setPermission(e.target.value as "view" | "comment" | "edit")}
aria-label="Permission level" aria-label="Permission level"
style={{ options={[
padding: "var(--nl-space-3)", { value: "view", label: "View only" },
border: "1px solid var(--nl-border-default)", { value: "comment", label: "Can comment" },
borderRadius: "var(--nl-radius-md)", { value: "edit", label: "Can edit" },
background: "var(--nl-input-bg)", ]}
color: "var(--nl-text-primary)", />
}}
>
<option value="view">View only</option>
<option value="comment">Can comment</option>
<option value="edit">Can edit</option>
</select>
<Button type="button" disabled={loading} loading={loading} onClick={() => void handleShareWithUser()} aria-label="Share with user"> <Button type="button" disabled={loading} loading={loading} onClick={() => void handleShareWithUser()} aria-label="Share with user">
Share Share
</Button> </Button>
{collaborators.length === 0 ? ( {collaborators.length === 0 ? (
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}> <p className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
No direct collaborators yet. No direct collaborators yet.
</p> </p>
) : ( ) : (
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}> <div className="grid gap-2">
{collaborators.map((collaborator) => ( {collaborators.map((collaborator) => (
<div key={collaborator.id ?? collaborator.sharedWithUserId} className="surface-muted" style={{ padding: "var(--nl-space-3)", display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap", alignItems: "center" }}> <AlertBanner
<span style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}> key={collaborator.id ?? collaborator.sharedWithUserId}
tone="neutral"
className="flex flex-wrap items-center justify-between gap-3"
>
<span className="text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
{collaborator.sharedWithUserId} · {collaborator.permission ?? "view"} {collaborator.sharedWithUserId} · {collaborator.permission ?? "view"}
</span> </span>
{collaborator.sharedWithUserId ? ( {collaborator.sharedWithUserId ? (
@ -280,7 +260,7 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
Remove Remove
</Button> </Button>
) : null} ) : null}
</div> </AlertBanner>
))} ))}
</div> </div>
)} )}
@ -288,8 +268,8 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
)} )}
{tab === "text" && ( {tab === "text" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}> <div className="grid gap-3">
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}> <p className="m-0 text-[color:var(--nl-text-secondary)]">
Copy the note content as plain text paste into email, WhatsApp, Messages, etc. Copy the note content as plain text paste into email, WhatsApp, Messages, etc.
</p> </p>
<Button type="button" disabled={loading} loading={loading} onClick={() => void handleCopyText()} aria-label="Copy note text"> <Button type="button" disabled={loading} loading={loading} onClick={() => void handleCopyText()} aria-label="Copy note text">
@ -299,17 +279,17 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
)} )}
{tab === "native" && ( {tab === "native" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}> <div className="grid gap-3">
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}> <p className="m-0 text-[color:var(--nl-text-secondary)]">
Open your device&apos;s native share sheet (AirDrop, Messages, email, etc.) Open your device&apos;s native share sheet (AirDrop, Messages, email, etc.)
</p> </p>
<Button type="button" disabled={loading} loading={loading} onClick={() => void handleNativeShare()} aria-label="Open share sheet"> <Button type="button" disabled={loading} loading={loading} onClick={() => void handleNativeShare()} aria-label="Open share sheet">
Open Share Sheet Open Share Sheet
</Button> </Button>
{typeof navigator !== "undefined" && !navigator.share && ( {typeof navigator !== "undefined" && !navigator.share && (
<p style={{ margin: 0, color: "var(--nl-warning)", fontSize: "var(--nl-fs-sm)" }}> <AlertBanner tone="warning">
Web Share API not supported in this browser. Web Share API not supported in this browser.
</p> </AlertBanner>
)} )}
</div> </div>
)} )}