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

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

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

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

300 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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&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 && (
<AlertBanner tone="warning">
Web Share API not supported in this browser.
</AlertBanner>
)}
</div>
)}
</Card>
</div>
);
}