161 lines
5.3 KiB
TypeScript
161 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { searchNoteSummaries, createNoteRelationship } from "@/lib/notes-client";
|
|
import type { NoteSummary } from "@/lib/types";
|
|
|
|
interface LinkNoteModalProps {
|
|
noteId: string;
|
|
workspaceId: string;
|
|
existingLinkedIds: string[];
|
|
onLinked: () => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const RELATIONSHIP_TYPES = ["related", "parent", "child", "blocks", "blocked_by", "duplicate"] as const;
|
|
|
|
export function LinkNoteModal({ noteId, workspaceId, existingLinkedIds, onLinked, onClose }: LinkNoteModalProps) {
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<NoteSummary[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [relationshipType, setRelationshipType] = useState<string>("related");
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [searched, setSearched] = useState(false);
|
|
|
|
async function handleSearch(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!query.trim()) return;
|
|
|
|
try {
|
|
const notes = await searchNoteSummaries(query.trim());
|
|
const filtered = notes.filter((n) => n.id !== noteId && !existingLinkedIds.includes(n.id));
|
|
setResults(filtered);
|
|
setSearched(true);
|
|
setSelectedId(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Search failed");
|
|
}
|
|
}
|
|
|
|
async function handleLink() {
|
|
if (!selectedId || saving) return;
|
|
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
await createNoteRelationship({
|
|
id: crypto.randomUUID(),
|
|
workspaceId,
|
|
fromNoteId: noteId,
|
|
toNoteId: selectedId,
|
|
relationshipType,
|
|
});
|
|
onLinked();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to link note");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="modal-overlay"
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
background: "var(--nl-overlay-scrim)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
zIndex: 1000,
|
|
}}
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<div
|
|
className="surface-card"
|
|
style={{
|
|
padding: "var(--nl-space-6)",
|
|
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>}
|
|
|
|
<form onSubmit={handleSearch} style={{ display: "flex", gap: "var(--nl-space-2)" }}>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search notes..."
|
|
aria-label="Search notes to link"
|
|
style={{ flex: 1 }}
|
|
autoFocus
|
|
/>
|
|
<button type="submit" className="btn btn-secondary">
|
|
Search
|
|
</button>
|
|
</form>
|
|
|
|
{searched && results.length === 0 && (
|
|
<div style={{ color: "var(--nl-text-secondary)", fontSize: "0.875rem" }}>No matching notes found.</div>
|
|
)}
|
|
|
|
{results.length > 0 && (
|
|
<div style={{ maxHeight: 200, overflowY: "auto", display: "grid", gap: "var(--nl-space-2)" }}>
|
|
{results.map((note) => (
|
|
<button
|
|
key={note.id}
|
|
type="button"
|
|
className={selectedId === note.id ? "surface-card" : "surface-muted"}
|
|
style={{
|
|
padding: "var(--nl-space-3)",
|
|
textAlign: "left",
|
|
cursor: "pointer",
|
|
border: selectedId === note.id ? "2px solid var(--nl-accent-primary)" : "2px solid 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>
|
|
</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>
|
|
)}
|
|
|
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-3)" }}>
|
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
|
Cancel
|
|
</button>
|
|
<button type="button" className="btn btn-primary" disabled={!selectedId || saving} onClick={handleLink}>
|
|
{saving ? "Linking..." : "Link"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|