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).
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { toast } from "@/lib/toast";
|
||
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";
|
||
|
||
interface ShareDialogProps {
|
||
noteId: string;
|
||
workspaceId: string;
|
||
noteTitle: string;
|
||
onClose: () => void;
|
||
}
|
||
|
||
type CollaboratorRow = {
|
||
id?: string;
|
||
sharedWithUserId?: string;
|
||
permission?: "view" | "comment" | "edit";
|
||
createdAt?: string;
|
||
};
|
||
|
||
export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDialogProps) {
|
||
const [tab, setTab] = useState<"link" | "user" | "text" | "native">("link");
|
||
const [userId, setUserId] = useState("");
|
||
const [permission, setPermission] = useState<"view" | "comment" | "edit">("view");
|
||
const [loading, setLoading] = useState(false);
|
||
const [publicLinks, setPublicLinks] = useState<PublicNoteShare[]>([]);
|
||
const [collaborators, setCollaborators] = useState<CollaboratorRow[]>([]);
|
||
|
||
const reloadSharingState = useCallback(async () => {
|
||
const [links, collabs] = await Promise.all([
|
||
listNoteShares(noteId, workspaceId).catch(() => ({ items: [] as PublicNoteShare[], total: 0 })),
|
||
listCollaborators(noteId, workspaceId).catch(() => ({ items: [] as unknown[], total: 0 })),
|
||
]);
|
||
setPublicLinks(links.items);
|
||
setCollaborators(collabs.items as CollaboratorRow[]);
|
||
}, [noteId, workspaceId]);
|
||
|
||
useEffect(() => {
|
||
void reloadSharingState();
|
||
}, [reloadSharingState]);
|
||
|
||
async function handleCopyLink() {
|
||
setLoading(true);
|
||
try {
|
||
const { shareToken, expiresAt } = await createNoteShare(noteId, workspaceId);
|
||
const url = `${getWebAppOrigin()}/share/${shareToken}`;
|
||
await navigator.clipboard.writeText(url);
|
||
await reloadSharingState();
|
||
toast.success(expiresAt ? `Share link copied; expires ${new Date(expiresAt).toLocaleDateString()}` : "Share link copied");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Failed to create share link");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleShareWithUser() {
|
||
if (!userId.trim()) {
|
||
toast.error("Please enter a user ID");
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
await shareNoteWithUser(noteId, workspaceId, userId.trim(), permission);
|
||
toast.success(`Shared with ${userId.trim()}`);
|
||
setUserId("");
|
||
await reloadSharingState();
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Failed to share");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleRevokeLink(shareToken: string) {
|
||
setLoading(true);
|
||
try {
|
||
await revokeNoteShare(noteId, workspaceId, shareToken);
|
||
await reloadSharingState();
|
||
toast.success("Public link revoked");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Failed to revoke link");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleRemoveCollaborator(targetUserId: string) {
|
||
setLoading(true);
|
||
try {
|
||
await removeCollaborator(noteId, targetUserId);
|
||
await reloadSharingState();
|
||
toast.success(`Access removed for ${targetUserId}`);
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Failed to remove access");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleCopyText() {
|
||
setLoading(true);
|
||
try {
|
||
const exported = await exportNoteText(noteId, workspaceId);
|
||
await navigator.clipboard.writeText(`${exported.title}\n\n${exported.plaintext}`);
|
||
toast.success("Note text copied — paste in email, WhatsApp, etc.");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Failed to export");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleNativeShare() {
|
||
if (!navigator.share) {
|
||
toast.error("Web Share API not supported in this browser");
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const exported = await exportNoteText(noteId, workspaceId);
|
||
await navigator.share({
|
||
title: noteTitle,
|
||
text: exported.plaintext.slice(0, 500),
|
||
url: `${getWebAppOrigin()}/notes/${noteId}`,
|
||
});
|
||
} catch (e) {
|
||
if ((e as Error)?.name !== "AbortError") {
|
||
toast.error("Share failed");
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
const tabs = [
|
||
{ key: "link" as const, label: "Copy Link" },
|
||
{ key: "user" as const, label: "Share with User" },
|
||
{ key: "text" as const, label: "Copy as Text" },
|
||
{ key: "native" as const, label: "Share Sheet" },
|
||
];
|
||
|
||
return (
|
||
<div
|
||
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();
|
||
}}
|
||
role="dialog"
|
||
aria-label="Share note"
|
||
>
|
||
<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"
|
||
className="text-[length:var(--nl-fs-xl)]"
|
||
>
|
||
×
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
{tabs.map((t) => (
|
||
<Button
|
||
key={t.key}
|
||
type="button"
|
||
onClick={() => setTab(t.key)}
|
||
variant={tab === t.key ? "primary" : "secondary"}
|
||
size="sm"
|
||
aria-label={t.label}
|
||
>
|
||
{t.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === "link" && (
|
||
<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 className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
|
||
No active public links.
|
||
</p>
|
||
) : (
|
||
<div className="grid gap-2">
|
||
{publicLinks.map((link) => (
|
||
<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>
|
||
</AlertBanner>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{tab === "user" && (
|
||
<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
|
||
type="text"
|
||
value={userId}
|
||
onChange={(e) => setUserId(e.target.value)}
|
||
placeholder="User ID"
|
||
aria-label="User ID to share with"
|
||
/>
|
||
<Select
|
||
value={permission}
|
||
onChange={(e) => setPermission(e.target.value as "view" | "comment" | "edit")}
|
||
aria-label="Permission level"
|
||
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 className="m-0 text-[length:var(--nl-fs-sm)] text-[color:var(--nl-text-secondary)]">
|
||
No direct collaborators yet.
|
||
</p>
|
||
) : (
|
||
<div className="grid gap-2">
|
||
{collaborators.map((collaborator) => (
|
||
<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 ? (
|
||
<Button type="button" variant="secondary" size="sm" disabled={loading} onClick={() => void handleRemoveCollaborator(collaborator.sharedWithUserId!)}>
|
||
Remove
|
||
</Button>
|
||
) : null}
|
||
</AlertBanner>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{tab === "text" && (
|
||
<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">
|
||
Copy Note Text
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{tab === "native" && (
|
||
<div className="grid gap-3">
|
||
<p className="m-0 text-[color:var(--nl-text-secondary)]">
|
||
Open your device'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 && (
|
||
<AlertBanner tone="warning">
|
||
Web Share API not supported in this browser.
|
||
</AlertBanner>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|