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 { useAuth } from "@/lib/auth";
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 { toast } from "@/lib/toast";
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"
description="Account, preferences, feedback, and session management."
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
</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 */}
<Card style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<Card padding="md" className="grid gap-3">
<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 ?? "—"}
</div>
</Card>
{/* Appearance */}
<Card style={{ padding: "var(--nl-space-5)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<Card padding="md" className="flex items-center justify-between">
<div className="grid gap-2">
<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>
<Button
onClick={toggle}
@ -96,38 +101,62 @@ export default function SettingsPage() {
</Card>
{/* 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>
{error && <div style={{ color: "var(--nl-danger)", fontSize: "var(--nl-fs-sm)" }}>{error}</div>}
{success && <div style={{ color: "var(--nl-status-success)", fontSize: "var(--nl-fs-sm)" }}>{success}</div>}
<form onSubmit={handleChangePassword} style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<input className="input-shell" type="password" placeholder="Current password" aria-label="Current password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required />
<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" }}>
{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 style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<Card padding="md" className="grid gap-3">
<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
</Button>
</Card>
</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>
<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{" "}
<code>{PRODUCT_ID}</code>.
</p>
<pre
className="surface-muted"
style={{ margin: 0, padding: "var(--nl-space-3)", fontSize: "var(--nl-fs-sm)", overflow: "auto", whiteSpace: "pre-wrap" }}
>
<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}
@ -137,29 +166,29 @@ MCP API base: ${MCP_SERVER_URL}
# "notelett": { "url": "${MCP_SERVER_URL}" }
# }`}
</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.
</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>
<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
create/revoke; use the platform admin or CLI when available.
</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>
<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{" "}
<code>{`${PRODUCT_ID}_offline_queue`}</code>). Reload or return online to flush.
</p>
<Button
type="button"
variant="secondary"
style={{ justifySelf: "start" }}
className="justify-self-start"
onClick={() => {
try {
getOfflineQueue();
@ -171,30 +200,47 @@ MCP API base: ${MCP_SERVER_URL}
>
Verify offline queue
</Button>
</section>
</Card>
{/* 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>
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr", gap: "var(--nl-space-3)" }}>
<select className="input-shell" value={feedbackType} onChange={(e) => setFeedbackType(e.target.value as typeof feedbackType)} aria-label="Feedback type">
<option value="bug">Bug</option>
<option value="feature">Feature request</option>
<option value="praise">Praise</option>
<option value="other">Other</option>
</select>
<input className="input-shell" placeholder="Title" aria-label="Feedback title" value={feedbackTitle} onChange={(e) => setFeedbackTitle(e.target.value)} />
<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 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
onClick={handleSubmitFeedback}
disabled={submittingFeedback || !feedbackTitle.trim()}
loading={submittingFeedback}
style={{ justifySelf: "start" }}
className="justify-self-start"
>
Send feedback
</Button>
</section>
</Card>
</AppShell>
);
}

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react";
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 { exportNoteText, listCollaborators, removeCollaborator, shareNoteWithUser } from "@/lib/intake-client";
import { getWebAppOrigin } from "@/lib/product-config";
@ -145,44 +145,29 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
return (
<div
style={{
position: "fixed",
inset: 0,
background: "var(--nl-overlay-scrim)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
className="fixed inset-0 z-[1000] flex items-center justify-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"
aria-label="Share note"
>
<Card
padding="lg"
style={{
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>
<Card padding="lg" className="grid w-[min(480px,90vw)] gap-4">
<div className="flex items-center justify-between">
<h2 className="m-0 text-[length:var(--nl-fs-xl)]">Share Note</h2>
<Button
type="button"
onClick={onClose}
aria-label="Close share dialog"
variant="ghost"
size="sm"
style={{
fontSize: "var(--nl-fs-xl)",
}}
className="text-[length:var(--nl-fs-xl)]"
>
×
</Button>
</div>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<Button
key={t.key}
@ -198,28 +183,32 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
</div>
{tab === "link" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
<div className="grid gap-3">
<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.
</p>
<Button type="button" disabled={loading} loading={loading} onClick={() => void handleCopyLink()} aria-label="Copy share link">
Create and Copy Link
</Button>
{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.
</p>
) : (
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<div className="grid gap-2">
{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" }}>
<span style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
<AlertBanner
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"}
</span>
<Button type="button" variant="secondary" size="sm" disabled={loading} onClick={() => void handleRevokeLink(link.shareToken)}>
Revoke
</Button>
</div>
</AlertBanner>
))}
</div>
)}
@ -227,52 +216,43 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
)}
{tab === "user" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
<div className="grid gap-3">
<p className="m-0 text-[color:var(--nl-text-secondary)]">
Share directly with a NoteLett user by their ID.
</p>
<input
<Input
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="User ID"
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}
onChange={(e) => setPermission(e.target.value as "view" | "comment" | "edit")}
aria-label="Permission level"
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)",
}}
>
<option value="view">View only</option>
<option value="comment">Can comment</option>
<option value="edit">Can edit</option>
</select>
options={[
{ value: "view", label: "View only" },
{ value: "comment", label: "Can comment" },
{ value: "edit", label: "Can edit" },
]}
/>
<Button type="button" disabled={loading} loading={loading} onClick={() => void handleShareWithUser()} aria-label="Share with user">
Share
</Button>
{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.
</p>
) : (
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<div className="grid gap-2">
{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" }}>
<span style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
<AlertBanner
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"}
</span>
{collaborator.sharedWithUserId ? (
@ -280,7 +260,7 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
Remove
</Button>
) : null}
</div>
</AlertBanner>
))}
</div>
)}
@ -288,8 +268,8 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
)}
{tab === "text" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
<div className="grid gap-3">
<p className="m-0 text-[color:var(--nl-text-secondary)]">
Copy the note content as plain text paste into email, WhatsApp, Messages, etc.
</p>
<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" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
<div className="grid gap-3">
<p className="m-0 text-[color:var(--nl-text-secondary)]">
Open your device&apos;s native share sheet (AirDrop, Messages, email, etc.)
</p>
<Button type="button" disabled={loading} loading={loading} onClick={() => void handleNativeShare()} aria-label="Open share sheet">
Open Share Sheet
</Button>
{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.
</p>
</AlertBanner>
)}
</div>
)}