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:
parent
a83e60a60a
commit
30a30ceb0f
@ -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 ?? "—"} · {user?.email ?? "—"} · {user?.role ?? "—"}
|
{user?.name ?? "—"} · {user?.email ?? "—"} · {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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,42 +112,35 @@ 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>
|
||||||
<Button type="submit" disabled={!canSubmit || saving} loading={saving}>
|
<Button type="submit" disabled={!canSubmit || saving} loading={saving}>
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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's native share sheet (AirDrop, Messages, email, etc.)
|
Open your device'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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user