release gate / check (push) Failing after 6m36s
Implement Notion-like upgrade phase 4: append-only page revisions with restore-as-a-new-version; recoverable bookmark/page/collection deletions whose restores never resurrect public flags or shares; owner-scoped JSON export; and explicit, revocable read-only collection sharing with owner-only mutations and no transitive third-party bookmark leaks. Add migration 009, API/docs, integration coverage, and page/collections/trash UI.
1045 lines
54 KiB
TypeScript
1045 lines
54 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState, type ClipboardEvent } from 'react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/navigation'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
ChevronLeft, ChevronRight, Code, CornerUpLeft, Download, Eye, FileText, GripVertical,
|
|
Heading1, Heading2, Heading3, History, Layers, Link2, List, ListOrdered, ListTodo, Minus, Pencil,
|
|
Pin, PinOff, Plus, Quote, RotateCcw, Search, Trash2, Type, X,
|
|
} from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
import { cn } from '@/lib/utils'
|
|
import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave'
|
|
import {
|
|
PAGES_CHANGED, collectionEmbedMarkdown, descendantPageIds, internalPageId, nextUntitledTitle,
|
|
notifyActivePage, notifyPagesChanged, pageLinkMarkdown, parsePageEmbed, type PageSummary,
|
|
} from '@/lib/pages'
|
|
import { reorderPages } from '@/lib/page-order'
|
|
import { fetchLinkPreview } from '@/lib/link-preview'
|
|
import { LinkPreviewCard } from '@/components/link-preview-card'
|
|
import type { Element, ElementContent } from 'hast'
|
|
|
|
export { PAGES_CHANGED }
|
|
|
|
// Notion-style slash menu: typing "/" at the start of a line offers blocks
|
|
// that insert their Markdown equivalent. The textarea stays the source of
|
|
// truth, so autosave and conflict handling are untouched.
|
|
const SLASH_BLOCKS = [
|
|
{ key: 'text', label: 'Text', desc: 'Plain paragraph', icon: Type, prefix: '' },
|
|
{ key: 'h1', label: 'Heading 1', desc: 'Large section heading', icon: Heading1, prefix: '# ' },
|
|
{ key: 'h2', label: 'Heading 2', desc: 'Medium section heading', icon: Heading2, prefix: '## ' },
|
|
{ key: 'h3', label: 'Heading 3', desc: 'Small section heading', icon: Heading3, prefix: '### ' },
|
|
{ key: 'todo', label: 'To-do list', desc: 'Track tasks with a checkbox', icon: ListTodo, prefix: '- [ ] ' },
|
|
{ key: 'bullet', label: 'Bulleted list', desc: 'Simple bulleted list', icon: List, prefix: '- ' },
|
|
{ key: 'num', label: 'Numbered list', desc: 'List with numbering', icon: ListOrdered, prefix: '1. ' },
|
|
{ key: 'quote', label: 'Quote', desc: 'Capture a quote', icon: Quote, prefix: '> ' },
|
|
{ key: 'div', label: 'Divider', desc: 'Visual section break', icon: Minus, prefix: '---\n' },
|
|
{ key: 'code', label: 'Code', desc: 'Code snippet block', icon: Code, prefix: '```\n' },
|
|
] as const
|
|
|
|
type LinkedBookmark = { id: string; url: string; title: string | null; favicon: string | null; ogImage: string | null; ogTitle: string | null; ogDescription: string | null; description: string | null }
|
|
type PageBacklink = { id: string; title: string; updatedAt: string }
|
|
type EmbeddedProperty = {
|
|
id: string; name: string
|
|
type: 'TEXT' | 'NUMBER' | 'SELECT' | 'MULTI_SELECT' | 'DATE' | 'CHECKBOX' | 'URL'
|
|
position: number; config: { options?: { id: string; label: string; color?: string }[]; mode?: 'date' | 'datetime' }
|
|
}
|
|
type EmbeddedView = { id: string; name: string; type: string; position: number }
|
|
type PageEmbed = {
|
|
collectionId: string; viewId: string | null; position: number
|
|
collection: { id: string; name: string; icon: string | null; description: string | null; properties: EmbeddedProperty[]; views: EmbeddedView[] }
|
|
view: EmbeddedView | null
|
|
}
|
|
type EmbeddedItem = { id: string; propertyValues: Record<string, unknown>; bookmark: LinkedBookmark }
|
|
type PageDetail = PageSummary & {
|
|
content: string; version: number
|
|
bookmarks: { bookmark: LinkedBookmark }[]
|
|
backlinks: PageBacklink[]
|
|
links: { id: string; title: string }[]
|
|
embeds: PageEmbed[]
|
|
}
|
|
type PageRevisionSummary = { id: string; version: number; title: string; createdAt: string; length: number }
|
|
type PageRevisionDetail = PageRevisionSummary & { content: string; icon: string | null }
|
|
|
|
const notify = notifyPagesChanged
|
|
const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } }
|
|
|
|
// Read-only live collection view embedded in a page (phase 3). It fetches the
|
|
// same authorized item endpoint the collection workspace uses, so a live
|
|
// embed never exposes a bookmark the viewer cannot already see and the
|
|
// server's filters/sorts still apply. It is a display, never a publication.
|
|
function PageEmbedCard({ embed }: { embed: PageEmbed }) {
|
|
const viewId = embed.viewId ?? embed.collection.views[0]?.id ?? null
|
|
const embedKey = `${embed.collectionId}|${viewId ?? ''}`
|
|
// The fetched result carries its embed key so switching collections never
|
|
// shows another embed's items for a frame; the effect only writes state
|
|
// asynchronously (no cascading synchronous setState).
|
|
const [loaded, setLoaded] = useState<{ key: string; items: EmbeddedItem[] | null; failed: boolean }>({ key: '', items: null, failed: false })
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const params = new URLSearchParams({ limit: '50' })
|
|
if (viewId) params.set('viewId', viewId)
|
|
fetch(`/api/collections/${embed.collectionId}/items?${params}`)
|
|
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('load failed'))))
|
|
.then((data: EmbeddedItem[]) => { if (!cancelled) setLoaded({ key: embedKey, items: data, failed: false }) })
|
|
.catch(() => { if (!cancelled) setLoaded({ key: embedKey, items: null, failed: true }) })
|
|
return () => { cancelled = true }
|
|
}, [embed.collectionId, embedKey, viewId])
|
|
|
|
const current = loaded.key === embedKey ? loaded : { key: embedKey, items: null, failed: false }
|
|
const items = current.items
|
|
const failed = current.failed
|
|
|
|
const properties = embed.collection.properties.slice(0, 4)
|
|
const optionLabel = (prop: EmbeddedProperty, value: unknown) => {
|
|
if (prop.type === 'CHECKBOX') return value === true ? 'Yes' : 'No'
|
|
if (prop.type === 'SELECT') return prop.config.options?.find((o) => o.id === value)?.label ?? '—'
|
|
if (prop.type === 'MULTI_SELECT') {
|
|
if (!Array.isArray(value)) return '—'
|
|
return value.map((id) => prop.config.options?.find((o) => o.id === id)?.label).filter(Boolean).join(', ') || '—'
|
|
}
|
|
if (prop.type === 'DATE' && typeof value === 'string' && value) {
|
|
if (prop.config.mode === 'date') return value.slice(0, 10)
|
|
const date = new Date(value)
|
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
|
|
}
|
|
if (value === null || value === undefined || value === '') return '—'
|
|
return String(value)
|
|
}
|
|
const viewLabel = embed.view?.name ?? embed.collection.views[0]?.name
|
|
|
|
return (
|
|
<section className="not-prose my-4 overflow-hidden rounded-lg border bg-card">
|
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
|
<Layers className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<Link href="/collections" className="truncate text-sm font-medium hover:underline">{embed.collection.name}</Link>
|
|
{viewLabel && <span className="truncate text-xs text-muted-foreground">· {viewLabel}</span>}
|
|
<span className="ml-auto text-xs text-muted-foreground">{items ? `${items.length} item${items.length === 1 ? '' : 's'}` : ''}</span>
|
|
</div>
|
|
{failed ? (
|
|
<p className="px-3 py-4 text-sm text-muted-foreground">This collection could not be loaded.</p>
|
|
) : items === null ? (
|
|
<p className="px-3 py-4 text-sm text-muted-foreground">Loading collection…</p>
|
|
) : items.length === 0 ? (
|
|
<p className="px-3 py-4 text-sm text-muted-foreground">No items match this view.</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-3 py-2 font-medium">Bookmark</th>
|
|
{properties.map((p) => <th key={p.id} className="px-3 py-2 font-medium">{p.name}</th>)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item) => (
|
|
<tr key={item.id} className="border-t">
|
|
<td className="max-w-[18rem] px-3 py-2">
|
|
<Link href={`/bookmarks?open=${item.bookmark.id}`} className="block truncate hover:underline">
|
|
{item.bookmark.title || item.bookmark.ogTitle || item.bookmark.url}
|
|
</Link>
|
|
<span className="block truncate text-xs text-muted-foreground">{host(item.bookmark.url)}</span>
|
|
</td>
|
|
{properties.map((p) => <td key={p.id} className="max-w-[12rem] truncate px-3 py-2 text-muted-foreground">{optionLabel(p, item.propertyValues?.[p.id])}</td>)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
const textOf = (nodes: ElementContent[]): string =>
|
|
nodes.map((n) => (n.type === 'text' ? n.value : n.type === 'element' ? textOf(n.children) : '')).join('')
|
|
|
|
// A paragraph whose only content is a single external link — a bare URL
|
|
// autolinked by GFM or a lone [label](url) — renders as a Notion-style
|
|
// preview card instead of a plain <p><a>. Links woven into sentences stay
|
|
// inline text; the Markdown source remains the source of truth either way.
|
|
function standaloneLink(node: Element | undefined): { href: string; label: string } | null {
|
|
if (!node) return null
|
|
const kids = node.children.filter((c) => !(c.type === 'text' && c.value.trim() === ''))
|
|
if (kids.length !== 1) return null
|
|
const a = kids[0]
|
|
if (a.type !== 'element' || a.tagName !== 'a') return null
|
|
const href = a.properties?.href
|
|
if (typeof href !== 'string' || !/^https?:\/\//i.test(href)) return null
|
|
return { href, label: textOf(a.children) }
|
|
}
|
|
|
|
export function PagesClient({ initialId }: { initialId: string | null }) {
|
|
const router = useRouter()
|
|
const [pages, setPages] = useState<PageSummary[]>([])
|
|
const [activeId, setActiveId] = useState<string | null>(initialId)
|
|
const [page, setPage] = useState<PageDetail | null>(null)
|
|
const [filter, setFilter] = useState('')
|
|
const [mode, setMode] = useState<'edit' | 'preview'>('preview')
|
|
const [title, setTitle] = useState('')
|
|
const [content, setContent] = useState('')
|
|
const [saveStates, setSaveStates] = useState<Record<string, PageSaveState>>({})
|
|
const [linkQuery, setLinkQuery] = useState('')
|
|
const [results, setResults] = useState<LinkedBookmark[]>([])
|
|
const [linkOpen, setLinkOpen] = useState(false)
|
|
const [pageLinkOpen, setPageLinkOpen] = useState(false)
|
|
const [pageLinkQuery, setPageLinkQuery] = useState('')
|
|
const [embedOpen, setEmbedOpen] = useState(false)
|
|
const [collections, setCollections] = useState<{ id: string; name: string; icon: string | null; views?: { id: string; name: string }[] }[]>([])
|
|
const [historyOpen, setHistoryOpen] = useState(false)
|
|
const [revisions, setRevisions] = useState<PageRevisionSummary[]>([])
|
|
const [revision, setRevision] = useState<PageRevisionDetail | null>(null)
|
|
const [historyBusy, setHistoryBusy] = useState<string | null>(null)
|
|
const [slash, setSlash] = useState<{ query: string; start: number } | null>(null)
|
|
const [slashIndex, setSlashIndex] = useState(0)
|
|
const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null)
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
const pendingCaret = useRef<number | null>(null)
|
|
const suppressTabClick = useRef(false)
|
|
const draggedTabRef = useRef<string | null>(null)
|
|
const activePage = useRef(initialId)
|
|
const loadSequence = useRef(0)
|
|
const selectionSequence = useRef(0)
|
|
// Optimistic concurrency (review DATA-01): PageAutosave tracks the server
|
|
// revision per page and sends it with content saves, so a newer save from
|
|
// another tab or device answers 409 instead of being silently overwritten.
|
|
const persistContent = useCallback(async (id: string, content: string, version: number | undefined) => {
|
|
const body = JSON.stringify(typeof version === 'number' ? { content, version } : { content })
|
|
let res: Response
|
|
try {
|
|
res = await fetch(`/api/pages/${id}`, {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body,
|
|
keepalive: new TextEncoder().encode(body).length < 60000,
|
|
})
|
|
} catch (error) {
|
|
toast.error('Could not save page. Your draft is kept in this tab; retry saving.')
|
|
throw error
|
|
}
|
|
if (res.status === 409) {
|
|
// Another session saved first. Review DATA-04: the queue enters a
|
|
// paused conflict state instead of adopting the remote version, so
|
|
// nothing overwrites the newer content until the user explicitly
|
|
// chooses to overwrite, discard, or copy the draft.
|
|
let serverVersion: number | undefined
|
|
try {
|
|
const latest = await fetch(`/api/pages/${id}`, { cache: 'no-store' }).then(r => (r.ok ? r.json() : null))
|
|
if (latest && typeof latest.version === 'number') serverVersion = latest.version
|
|
} catch { /* keep the stale base; the conflict state pauses autosave either way */ }
|
|
toast.error('This page was saved in another tab or device. Autosave is paused so nothing is overwritten until you resolve the conflict.')
|
|
throw new PageConflictError(serverVersion)
|
|
}
|
|
if (!res.ok) {
|
|
toast.error('Could not save page. Your draft is kept in this tab; retry saving.')
|
|
throw new Error('Save failed')
|
|
}
|
|
const saved = await res.json().catch(() => null)
|
|
return saved && typeof saved.version === 'number' ? saved.version : undefined
|
|
}, [])
|
|
const [autosave] = useState(() => new PageAutosave(persistContent, (id, state) => setSaveStates(previous => ({ ...previous, [id]: state }))))
|
|
const saveState = activeId ? saveStates[activeId] ?? 'saved' : 'saved'
|
|
|
|
useEffect(() => {
|
|
const beforeUnload = (event: BeforeUnloadEvent) => {
|
|
if (autosave.hasUnsaved()) { event.preventDefault(); event.returnValue = '' }
|
|
}
|
|
const flush = () => { void autosave.flushAll() }
|
|
window.addEventListener('beforeunload', beforeUnload)
|
|
window.addEventListener('pagehide', flush)
|
|
return () => {
|
|
window.removeEventListener('beforeunload', beforeUnload)
|
|
window.removeEventListener('pagehide', flush)
|
|
flush()
|
|
}
|
|
}, [autosave])
|
|
|
|
const loadPages = useCallback(async () => {
|
|
const res = await fetch('/api/pages')
|
|
if (!res.ok) return [] as PageSummary[]
|
|
const data: PageSummary[] = await res.json()
|
|
setPages(data)
|
|
return data
|
|
}, [])
|
|
|
|
const loadPage = useCallback(async (id: string) => {
|
|
const sequence = ++loadSequence.current
|
|
const res = await fetch(`/api/pages/${id}`)
|
|
if (sequence !== loadSequence.current || activePage.current !== id) return
|
|
if (!res.ok) { setPage(null); notifyActivePage(null); return }
|
|
const data: PageDetail = await res.json()
|
|
if (sequence !== loadSequence.current || activePage.current !== id) return
|
|
if (typeof data.version === 'number') autosave.setVersion(data.id, data.version)
|
|
// Review DATA-03: recovery compares the draft's original base version
|
|
// with the freshly fetched server version; a mismatch pauses autosave in
|
|
// the conflict state instead of silently rebasing onto the newer save.
|
|
const draft = autosave.restore(id, data.version)
|
|
setPage(data)
|
|
setTitle(data.title)
|
|
setContent(draft ?? data.content)
|
|
notifyActivePage(data.title)
|
|
if (draft !== undefined || !data.content) setMode('edit')
|
|
}, [autosave])
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => loadPages().then((data) => {
|
|
if (!activePage.current && data.length) { activePage.current = data[0].id; setActiveId(data[0].id) }
|
|
}).catch((e) => console.error('Failed to load pages', e)), 0)
|
|
return () => clearTimeout(timer)
|
|
}, [loadPages])
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
if (activeId) loadPage(activeId).catch((e) => console.error('Failed to load page', e))
|
|
else setPage(null)
|
|
}, 0)
|
|
return () => clearTimeout(timer)
|
|
}, [activeId, loadPage])
|
|
|
|
const select = async (id: string) => {
|
|
const sequence = ++selectionSequence.current
|
|
if (activePage.current && !await autosave.flush(activePage.current)) return
|
|
if (sequence !== selectionSequence.current) return
|
|
activePage.current = id
|
|
setActiveId(id)
|
|
router.replace(`/pages?id=${id}`)
|
|
}
|
|
|
|
// The sidebar can create or open pages while this workspace stays mounted.
|
|
useEffect(() => {
|
|
if (!initialId || initialId === activePage.current) return
|
|
void loadPages()
|
|
void select(initialId)
|
|
}, [initialId])
|
|
|
|
// Sidebar mutations (rename, pin, delete) announce themselves; keep the
|
|
// page list in step and drop the detail view when the open page is gone.
|
|
useEffect(() => {
|
|
const reload = () => {
|
|
void loadPages().then((data) => {
|
|
if (activePage.current && !data.some((p) => p.id === activePage.current)) {
|
|
activePage.current = null
|
|
setActiveId(null)
|
|
setPage(null)
|
|
notifyActivePage(null)
|
|
router.replace('/pages')
|
|
}
|
|
})
|
|
}
|
|
window.addEventListener(PAGES_CHANGED, reload)
|
|
return () => window.removeEventListener(PAGES_CHANGED, reload)
|
|
}, [loadPages, router])
|
|
|
|
// Leaving the pages workspace clears the breadcrumb title.
|
|
useEffect(() => () => { notifyActivePage(null) }, [])
|
|
|
|
const patch = async (body: Record<string, unknown>) => {
|
|
if (!activeId || page?.id !== activeId) return null
|
|
const res = await fetch(`/api/pages/${page.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
|
if (!res.ok) { toast.error('Could not save page'); return null }
|
|
// Review CR-17: metadata responses (rename/pin/link changes) carry the
|
|
// CURRENT content revision but never require or acknowledge a content
|
|
// base — adopting it here would silently rebase an in-flight stale draft
|
|
// onto another editor's save and authorize overwriting it. Only content
|
|
// saves (persistContent) and fresh page loads may move the version map.
|
|
return res
|
|
}
|
|
|
|
const createPage = async () => {
|
|
if (activePage.current && !await autosave.flush(activePage.current)) return
|
|
const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: nextUntitledTitle(pages.map((p) => p.title)) }) })
|
|
if (!res.ok) { toast.error('Could not create page'); return }
|
|
const p = await res.json()
|
|
await loadPages()
|
|
notify()
|
|
setMode('edit')
|
|
select(p.id)
|
|
}
|
|
|
|
const onContentChange = (v: string) => {
|
|
if (!page || page.id !== activeId) return
|
|
setContent(v)
|
|
autosave.edit(page.id, v)
|
|
}
|
|
|
|
// Insert generated Markdown at the caret (or at the end when the editor is
|
|
// not focused). The content stays the source of truth: page links and
|
|
// embeds are resolved by the server from this text.
|
|
const insertSnippet = (snippet: string) => {
|
|
if (!page || page.id !== activeId) return
|
|
const ta = textareaRef.current
|
|
const caret = mode === 'edit' && ta ? ta.selectionStart : content.length
|
|
const before = content.slice(0, caret)
|
|
const after = content.slice(caret)
|
|
const lead = before && !before.endsWith('\n') ? '\n\n' : ''
|
|
const trail = after.startsWith('\n') ? '' : '\n'
|
|
const next = before + lead + snippet + trail + after
|
|
pendingCaret.current = before.length + lead.length + snippet.length
|
|
setMode('edit')
|
|
onContentChange(next)
|
|
}
|
|
|
|
const setParent = async (value: string) => {
|
|
if (!page) return
|
|
const parentId = value || null
|
|
if (await patch({ parentId })) {
|
|
setPage((current) => current?.id === page.id ? { ...current, parentId } : current)
|
|
await loadPages(); notify()
|
|
}
|
|
}
|
|
|
|
const openHistory = async () => {
|
|
if (!page) return
|
|
setHistoryOpen(true)
|
|
setRevision(null)
|
|
try {
|
|
const res = await fetch(`/api/pages/${page.id}/revisions`, { cache: 'no-store' })
|
|
setRevisions(res.ok ? await res.json() : [])
|
|
if (!res.ok) toast.error('Could not load page history')
|
|
} catch { toast.error('Could not load page history') }
|
|
}
|
|
const viewRevision = async (id: string) => {
|
|
if (!page) return
|
|
setHistoryBusy(id)
|
|
try {
|
|
const res = await fetch(`/api/pages/${page.id}/revisions/${id}`, { cache: 'no-store' })
|
|
if (res.ok) setRevision(await res.json())
|
|
else toast.error('Could not load this version')
|
|
} catch { toast.error('Could not load this version') }
|
|
finally { setHistoryBusy(null) }
|
|
}
|
|
const restoreRevision = async (rev: PageRevisionSummary) => {
|
|
if (!page) return
|
|
if (!confirm(`Restore version ${rev.version}? The current content is kept in history.`)) return
|
|
setHistoryBusy(rev.id)
|
|
try {
|
|
if (!await autosave.flush(page.id)) { toast.error('Resolve the unsaved draft before restoring history'); return }
|
|
const latest = await fetch(`/api/pages/${page.id}`, { cache: 'no-store' })
|
|
if (!latest.ok) { toast.error('Could not reload the page'); return }
|
|
const current: PageDetail = await latest.json()
|
|
const res = await fetch(`/api/pages/${page.id}/revisions/${rev.id}/restore`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ version: current.version }),
|
|
})
|
|
if (res.status === 409) {
|
|
toast.error('The page changed elsewhere. Reload it before restoring history.')
|
|
await loadPage(page.id)
|
|
return
|
|
}
|
|
if (!res.ok) { toast.error((await res.json().catch(() => null))?.error ?? 'Could not restore this version'); return }
|
|
const restored: PageDetail = await res.json()
|
|
autosave.discard(page.id)
|
|
autosave.setVersion(page.id, restored.version)
|
|
setPage(restored)
|
|
setTitle(restored.title)
|
|
setContent(restored.content)
|
|
setMode('preview')
|
|
notifyActivePage(restored.title)
|
|
setHistoryOpen(false)
|
|
toast.success(`Restored version ${rev.version} as version ${restored.version}`)
|
|
await loadPages(); notify()
|
|
} catch { toast.error('Could not restore this version') }
|
|
finally { setHistoryBusy(null) }
|
|
}
|
|
|
|
// Show the block menu only while the caret sits on a bare "/token" line.
|
|
const updateSlash = (text: string, caret: number) => {
|
|
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
|
|
const line = text.slice(lineStart, caret)
|
|
const m = /^\/([\w-]*)$/.exec(line)
|
|
if (m) { setSlash({ query: m[1].toLowerCase(), start: lineStart }); setSlashIndex(0) }
|
|
else setSlash(null)
|
|
}
|
|
|
|
const slashFiltered = SLASH_BLOCKS.filter((b) => !slash?.query || b.key.includes(slash.query) || b.label.toLowerCase().includes(slash.query))
|
|
|
|
const applyBlock = (block: (typeof SLASH_BLOCKS)[number]) => {
|
|
const ta = textareaRef.current
|
|
if (!slash || !ta) return
|
|
const caret = ta.selectionStart
|
|
const next = content.slice(0, slash.start) + block.prefix + content.slice(caret)
|
|
pendingCaret.current = slash.start + block.prefix.length
|
|
onContentChange(next)
|
|
setSlash(null)
|
|
}
|
|
|
|
// Restore the caret after a programmatic content rewrite (slash insert,
|
|
// "[] " shortcut). Runs once the new content has reached the textarea.
|
|
useEffect(() => {
|
|
if (pendingCaret.current === null || mode !== 'edit') return
|
|
const ta = textareaRef.current
|
|
if (!ta) return
|
|
const c = pendingCaret.current
|
|
pendingCaret.current = null
|
|
ta.focus()
|
|
ta.setSelectionRange(c, c)
|
|
}, [content, mode])
|
|
|
|
const saveTitle = async () => {
|
|
if (!page) return
|
|
const t = title.trim()
|
|
// Keep the generated "Untitled N" name until the user gives a real one.
|
|
if (!t) { setTitle(page.title); return }
|
|
if (t === page.title) return
|
|
if (await patch({ title: t })) {
|
|
setPage(current => current?.id === page.id ? { ...current, title: t } : current)
|
|
notifyActivePage(t)
|
|
await loadPages(); notify()
|
|
}
|
|
}
|
|
|
|
const togglePin = async () => {
|
|
if (!page) return
|
|
if (await patch({ pinned: !page.pinned })) {
|
|
setPage(current => current?.id === page.id ? { ...current, pinned: !page.pinned } : current)
|
|
await loadPages(); notify()
|
|
}
|
|
}
|
|
|
|
const move = async (dir: -1 | 1) => {
|
|
if (!activeId) return
|
|
const idx = pages.findIndex((p) => p.id === activeId)
|
|
const target = pages[idx + dir]
|
|
if (idx < 0 || !target) return
|
|
const next = reorderPages(pages, activeId, target.id, dir > 0)
|
|
if (next === 'blocked') { toast.error('Pinned and unpinned pages keep separate groups'); return }
|
|
if (next) await savePageOrder(next)
|
|
}
|
|
|
|
const savePageOrder = async (next: PageSummary[]) => {
|
|
setPages(next)
|
|
const res = await fetch('/api/pages', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: next.map((p) => p.id) }) })
|
|
if (!res.ok) { toast.error('Could not reorder pages'); await loadPages(); return }
|
|
await loadPages()
|
|
notify()
|
|
}
|
|
|
|
const dropTab = (target: PageSummary, after: boolean) => {
|
|
const source = draggedTabRef.current
|
|
draggedTabRef.current = null
|
|
setTabDrop(null)
|
|
if (!source) return
|
|
const next = reorderPages(pages, source, target.id, after)
|
|
if (next === 'blocked') { toast.error('Pinned and unpinned pages keep separate groups'); return }
|
|
if (next) void savePageOrder(next)
|
|
}
|
|
|
|
const download = async () => {
|
|
if (!activeId) return
|
|
// A paused conflict can never flush; exporting the server copy is still
|
|
// useful, so only ordinary save failures block the download.
|
|
if (autosave.conflictVersion(activeId) === null && !await autosave.flush(activeId)) return
|
|
const a = document.createElement('a')
|
|
a.href = `/api/pages/${activeId}/markdown`
|
|
a.download = ''
|
|
document.body.appendChild(a); a.click(); a.remove()
|
|
}
|
|
|
|
// Explicit conflict resolutions (review DATA-04). Typing is never consent;
|
|
// each path is a deliberate click.
|
|
const overwriteRemote = async () => {
|
|
if (!activeId) return
|
|
if (await autosave.resolveOverwrite(activeId)) toast.success('Your draft replaced the newer version')
|
|
else toast.error('Could not save page. Your draft is kept in this tab; retry saving.')
|
|
}
|
|
const discardDraft = async () => {
|
|
if (!activeId) return
|
|
autosave.discard(activeId)
|
|
await loadPage(activeId)
|
|
}
|
|
const copyDraft = async () => {
|
|
const res = await fetch('/api/pages', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: `${page?.title ?? 'Untitled'} (draft copy)`, content }),
|
|
})
|
|
if (!res.ok) { toast.error('Could not create the copy'); return }
|
|
toast.success('Draft copied to a new page')
|
|
await loadPages(); notify()
|
|
}
|
|
|
|
const remove = async () => {
|
|
if (!page || !confirm(`Move page "${page.title}" to the trash? Bookmarks are kept and it can be restored.`)) return
|
|
if (!await autosave.flush(page.id)) return
|
|
const res = await fetch(`/api/pages/${page.id}`, { method: 'DELETE' })
|
|
if (!res.ok) { toast.error('Could not delete page'); return }
|
|
autosave.forget(page.id)
|
|
const data = await loadPages(); notify()
|
|
if (data.length) select(data[0].id)
|
|
else { activePage.current = null; setActiveId(null); router.replace('/pages') }
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!embedOpen) return
|
|
fetch('/api/collections')
|
|
.then((res) => (res.ok ? res.json() : []))
|
|
.then((data) => setCollections(Array.isArray(data) ? data : []))
|
|
.catch(() => { /* transient; reopening retries */ })
|
|
}, [embedOpen])
|
|
|
|
useEffect(() => {
|
|
if (!linkOpen) return
|
|
const q = linkQuery.trim()
|
|
const t = setTimeout(async () => {
|
|
try {
|
|
const params = new URLSearchParams({ limit: '8' })
|
|
if (q) params.set('search', q)
|
|
const res = await fetch(`/api/bookmarks?${params}`)
|
|
if (res.ok) setResults((await res.json()).slice(0, 8))
|
|
} catch { /* transient; retried on next keystroke */ }
|
|
}, 250)
|
|
return () => clearTimeout(t)
|
|
}, [linkQuery, linkOpen])
|
|
|
|
const addBookmark = async (bookmarkId: string) => {
|
|
if (activeId && !await autosave.flush(activeId)) return
|
|
if (await patch({ addBookmarkId: bookmarkId })) {
|
|
setLinkOpen(false); setLinkQuery('')
|
|
if (activeId) await loadPage(activeId)
|
|
await loadPages()
|
|
}
|
|
}
|
|
|
|
const addUrl = async () => {
|
|
let url = linkQuery.trim()
|
|
if (!url) return
|
|
if (!/^https?:\/\//i.test(url)) url = `https://${url}`
|
|
const res = await fetch('/api/bookmarks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) })
|
|
if (!res.ok) { toast.error('Could not save URL'); return }
|
|
const b = await res.json()
|
|
await addBookmark(b.id)
|
|
toast.success('Link added')
|
|
}
|
|
|
|
const removeBookmark = async (bookmarkId: string) => {
|
|
if (await patch({ removeBookmarkId: bookmarkId })) {
|
|
if (page) setPage({ ...page, bookmarks: page.bookmarks.filter((x) => x.bookmark.id !== bookmarkId) })
|
|
await loadPages()
|
|
}
|
|
}
|
|
|
|
// Notion-style paste: a bare URL lands in the textarea untouched, and once
|
|
// its preview resolves we offer to upgrade it to a titled Markdown link.
|
|
// Nothing is rewritten without an explicit click, so undo stays intact.
|
|
const offerTitleForPastedUrl = (url: string) => {
|
|
void fetchLinkPreview(url).then((p) => {
|
|
const raw = p?.ogTitle || p?.title
|
|
if (!raw) return
|
|
const title = raw.replace(/[\[\]\n]/g, ' ').trim()
|
|
if (!title || title === url) return
|
|
toast.info('Paste as titled link?', {
|
|
description: title,
|
|
duration: 10000,
|
|
action: {
|
|
label: 'Use title',
|
|
onClick: () => {
|
|
const ta = textareaRef.current
|
|
if (!ta) return
|
|
const idx = ta.value.indexOf(url)
|
|
if (idx < 0) return
|
|
const markdown = `[${title}](${url})`
|
|
pendingCaret.current = idx + markdown.length
|
|
onContentChange(ta.value.slice(0, idx) + markdown + ta.value.slice(idx + url.length))
|
|
},
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
const onPaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
const text = e.clipboardData.getData('text/plain').trim()
|
|
if (/^https?:\/\/\S+$/i.test(text)) offerTitleForPastedUrl(text)
|
|
}
|
|
|
|
const visible = pages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()))
|
|
const looksLikeUrl = /^(https?:\/\/)?[\w-]+(\.[\w-]+)+/i.test(linkQuery.trim())
|
|
// A page may move under any page except itself or one of its descendants
|
|
// (the server rejects that cycle even if a stale client tries it).
|
|
const blockedParents = page ? descendantPageIds(pages, page.id) : new Set<string>()
|
|
const parentOptions = page
|
|
? pages.filter((p) => p.id !== page.id && !blockedParents.has(p.id))
|
|
: []
|
|
const pageLinkOptions = pages.filter((p) => p.id !== activeId && p.title.toLowerCase().includes(pageLinkQuery.toLowerCase()))
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<div className="flex items-center gap-2 border-b border-border/60 px-4 py-2">
|
|
<div className="relative w-40 shrink-0">
|
|
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter pages" className="h-8 pl-7 text-sm" />
|
|
</div>
|
|
<div className="flex flex-1 items-center gap-1 overflow-x-auto">
|
|
{visible.map((p) => (
|
|
<div
|
|
key={p.id}
|
|
data-page-tab={p.id}
|
|
onDragOver={(e) => {
|
|
if (!draggedTabRef.current || draggedTabRef.current === p.id) return
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
setTabDrop({ id: p.id, after: e.clientX > rect.left + rect.width / 2 })
|
|
}}
|
|
onDragLeave={(e) => {
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setTabDrop(null)
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault()
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
dropTab(p, e.clientX > rect.left + rect.width / 2)
|
|
}}
|
|
title={filter.trim() ? 'Clear the filter to reorder pages' : 'Drag the grip to reorder page'}
|
|
className={cn(
|
|
'group/tab flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors',
|
|
p.id === activeId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
tabDrop?.id === p.id && 'ring-1 ring-primary/50',
|
|
)}
|
|
>
|
|
{!filter.trim() && (
|
|
<span
|
|
data-page-tab-drag
|
|
draggable
|
|
onDragStart={(e) => {
|
|
suppressTabClick.current = true
|
|
draggedTabRef.current = p.id
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
e.dataTransfer.setData('text/plain', p.id)
|
|
}}
|
|
onDragEnd={() => {
|
|
draggedTabRef.current = null
|
|
setTabDrop(null)
|
|
setTimeout(() => { suppressTabClick.current = false }, 0)
|
|
}}
|
|
className="cursor-grab text-muted-foreground/40 opacity-0 transition-opacity hover:text-muted-foreground active:cursor-grabbing group-hover/tab:opacity-100"
|
|
aria-label={`Drag page ${p.title}`}
|
|
>
|
|
<GripVertical className="h-3 w-3" />
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => { if (!suppressTabClick.current) select(p.id) }}
|
|
aria-label={p.title}
|
|
className="flex min-w-0 items-center gap-1.5"
|
|
>
|
|
{p.pinned ? <Pin className="h-3 w-3" /> : <FileText className="h-3 w-3" />}
|
|
<span className="max-w-[10rem] truncate">{p.title}</span>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<Button size="sm" onClick={createPage}><Plus className="mr-1 h-4 w-4" />New page</Button>
|
|
</div>
|
|
|
|
{!page || page.id !== activeId ? (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
|
|
<FileText className="h-10 w-10 text-muted-foreground" />
|
|
<h2 className="font-display text-xl font-semibold">{pages.length ? 'Select a page' : 'No pages yet'}</h2>
|
|
<p className="max-w-sm text-sm text-muted-foreground">Pages are Markdown notes that collect links into a workspace. A bookmark can live on many pages.</p>
|
|
<Button onClick={createPage}><Plus className="mr-1 h-4 w-4" />Create a page</Button>
|
|
</div>
|
|
) : (
|
|
<div className="mx-auto w-full max-w-4xl flex-1 overflow-y-auto px-6 py-8">
|
|
<div className="mb-6 flex flex-wrap items-center gap-2">
|
|
<input value={title} onChange={(e) => setTitle(e.target.value)} onBlur={saveTitle} onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()} className="min-w-0 flex-1 bg-transparent font-display text-3xl font-bold outline-none" aria-label="Page title" />
|
|
<span className="text-xs text-muted-foreground">{saveState === 'saving' ? 'Saving…' : saveState === 'dirty' ? 'Unsaved' : saveState === 'conflict' ? 'Conflict' : 'Saved'}</span>
|
|
{saveState === 'dirty' && <Button size="sm" variant="outline" onClick={() => autosave.flush(page.id)}>Save now</Button>}
|
|
<Button variant="ghost" size="icon-sm" title={mode === 'edit' ? 'Preview' : 'Edit'} onClick={() => { setMode(mode === 'edit' ? 'preview' : 'edit'); setSlash(null) }}>{mode === 'edit' ? <Eye className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}</Button>
|
|
<Button variant="ghost" size="icon-sm" title={page.pinned ? 'Unpin' : 'Pin to sidebar'} onClick={togglePin}>{page.pinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}</Button>
|
|
<Button variant="ghost" size="icon-sm" title="Move tab left" onClick={() => move(-1)}><ChevronLeft className="h-4 w-4" /></Button>
|
|
<Button variant="ghost" size="icon-sm" title="Move tab right" onClick={() => move(1)}><ChevronRight className="h-4 w-4" /></Button>
|
|
<Button variant="ghost" size="icon-sm" title="Download .md" onClick={download}><Download className="h-4 w-4" /></Button>
|
|
<Button variant="ghost" size="icon-sm" title="Version history" onClick={() => void openHistory()}><History className="h-4 w-4" /></Button>
|
|
<Button variant="ghost" size="icon-sm" title="Move to trash" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
|
</div>
|
|
|
|
<div className="mb-4 flex flex-wrap items-center gap-2 text-sm">
|
|
<label className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
|
<CornerUpLeft className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="shrink-0">Parent</span>
|
|
<select
|
|
value={page.parentId ?? ''}
|
|
onChange={(e) => void setParent(e.target.value)}
|
|
aria-label="Parent page"
|
|
className="h-8 max-w-[16rem] rounded-md border bg-background px-2 text-sm text-foreground"
|
|
>
|
|
<option value="">Top level</option>
|
|
{parentOptions.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
|
|
</select>
|
|
</label>
|
|
<Popover open={pageLinkOpen} onOpenChange={(open) => { setPageLinkOpen(open); if (!open) setPageLinkQuery('') }}>
|
|
<PopoverTrigger asChild><Button size="sm" variant="outline"><FileText className="mr-1 h-3.5 w-3.5" />Link a page</Button></PopoverTrigger>
|
|
<PopoverContent align="start" className="w-80 p-2">
|
|
<Input autoFocus value={pageLinkQuery} onChange={(e) => setPageLinkQuery(e.target.value)} placeholder="Find a page to link" className="h-8 text-sm" />
|
|
<div className="mt-2 max-h-64 overflow-y-auto">
|
|
{pageLinkOptions.map((p) => (
|
|
<button key={p.id} onClick={() => { insertSnippet(pageLinkMarkdown(p)); setPageLinkOpen(false) }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted">
|
|
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">{p.title}</span>
|
|
</button>
|
|
))}
|
|
{pageLinkOptions.length === 0 && <p className="px-2 py-3 text-center text-xs text-muted-foreground">No matching pages</p>}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<Popover open={embedOpen} onOpenChange={setEmbedOpen}>
|
|
<PopoverTrigger asChild><Button size="sm" variant="outline"><Layers className="mr-1 h-3.5 w-3.5" />Embed a collection</Button></PopoverTrigger>
|
|
<PopoverContent align="start" className="w-80 p-2">
|
|
<div className="max-h-64 overflow-y-auto">
|
|
{collections.map((c) => (
|
|
<button key={c.id} onClick={() => { insertSnippet(collectionEmbedMarkdown(c.id, c.views?.[0]?.id)); setEmbedOpen(false) }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted">
|
|
<Layers className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">{c.name}</span>
|
|
</button>
|
|
))}
|
|
{collections.length === 0 && <p className="px-2 py-3 text-center text-xs text-muted-foreground">No collections yet. Create one from Collections first.</p>}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{saveState === 'conflict' && (
|
|
<div role="alert" className="mb-4 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
|
<p className="font-medium text-foreground">This page was changed elsewhere</p>
|
|
<p className="mt-1 text-muted-foreground">
|
|
Your local draft is based on an older version — or its base version is unknown —
|
|
so autosave is paused and nothing is overwritten until you choose. Your draft
|
|
stays in the editor below.
|
|
</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Button size="sm" variant="destructive" onClick={overwriteRemote}>Overwrite with my draft</Button>
|
|
<Button size="sm" variant="outline" onClick={copyDraft}>Save my draft as a new page</Button>
|
|
<Button size="sm" variant="outline" onClick={discardDraft}>Discard my draft and load latest</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{mode === 'edit' ? (
|
|
<div className="relative">
|
|
<Textarea
|
|
ref={textareaRef}
|
|
value={content}
|
|
onChange={(e) => { onContentChange(e.target.value); updateSlash(e.target.value, e.target.selectionStart) }}
|
|
onClick={(e) => updateSlash(e.currentTarget.value, e.currentTarget.selectionStart)}
|
|
onPaste={onPaste}
|
|
onBlur={() => setSlash(null)}
|
|
onKeyDown={(e) => {
|
|
// While the slash menu is open, arrows/Enter/Tab/Escape drive it.
|
|
if (slash && slashFiltered.length > 0) {
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex((i) => (i + 1) % slashFiltered.length); return }
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex((i) => (i - 1 + slashFiltered.length) % slashFiltered.length); return }
|
|
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); applyBlock(slashFiltered[Math.min(slashIndex, slashFiltered.length - 1)]); return }
|
|
if (e.key === 'Escape') { e.preventDefault(); setSlash(null); return }
|
|
}
|
|
// Notion Markdown shortcut: "[]" + space becomes a to-do item.
|
|
if (e.key === ' ') {
|
|
const ta = e.currentTarget
|
|
const caret = ta.selectionStart
|
|
const lineStart = ta.value.lastIndexOf('\n', caret - 1) + 1
|
|
if (ta.value.slice(lineStart, caret) === '[]') {
|
|
e.preventDefault()
|
|
const next = ta.value.slice(0, lineStart) + '- [ ] ' + ta.value.slice(caret)
|
|
pendingCaret.current = lineStart + '- [ ] '.length
|
|
onContentChange(next)
|
|
}
|
|
}
|
|
}}
|
|
placeholder="Write Markdown notes… type / for blocks, paste links, lists, thoughts."
|
|
className="min-h-[260px] font-mono text-sm"
|
|
/>
|
|
{slash && slashFiltered.length > 0 && (
|
|
<div
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
role="listbox"
|
|
aria-label="Blocks to insert"
|
|
className="absolute left-2 top-full z-20 mt-1 w-72 rounded-lg border bg-popover p-1 shadow-lg"
|
|
>
|
|
<p className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Basic blocks</p>
|
|
<div className="max-h-64 overflow-y-auto">
|
|
{slashFiltered.map((b, i) => (
|
|
<button
|
|
key={b.key}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={i === slashIndex}
|
|
onMouseEnter={() => setSlashIndex(i)}
|
|
onClick={() => applyBlock(b)}
|
|
className={cn('flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left', i === slashIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50')}
|
|
>
|
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded border bg-card">
|
|
<b.icon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span className="block text-sm font-medium">{b.label}</span>
|
|
<span className="block truncate text-xs text-muted-foreground">{b.desc}</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="prose prose-sm max-w-none dark:prose-invert min-h-[80px] cursor-text" onDoubleClick={() => setMode('edit')}>
|
|
{content ? (
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={{
|
|
p: ({ node, ...props }) => {
|
|
// A paragraph that is exactly an embed marker renders a
|
|
// live, read-only collection view instead of raw text.
|
|
const marker = node ? parsePageEmbed(textOf(node.children)) : null
|
|
if (marker) {
|
|
const embed = page.embeds.find((e) => e.collectionId === marker.collectionId)
|
|
return embed ? (
|
|
<PageEmbedCard embed={embed} />
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">This embedded collection is unavailable or not yours.</p>
|
|
)
|
|
}
|
|
const link = standaloneLink(node)
|
|
return link ? (
|
|
<LinkPreviewCard url={link.href} label={link.label !== link.href ? link.label : undefined} />
|
|
) : (
|
|
<p {...props} />
|
|
)
|
|
},
|
|
a: ({ href, ...props }) => {
|
|
const target = href ? internalPageId(href) : null
|
|
if (target) {
|
|
return <Link href={`/pages?id=${target}`} className="text-primary underline-offset-2 hover:underline" {...props} />
|
|
}
|
|
return <a {...props} href={href} target="_blank" rel="noopener noreferrer" />
|
|
},
|
|
}}
|
|
>{content}</ReactMarkdown>
|
|
) : (
|
|
<p className="text-muted-foreground">No notes yet. Double-click or press the pencil to write.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-10 mb-3 flex items-center justify-between">
|
|
<h3 className="font-display text-lg font-semibold">Links on this page <span className="text-sm font-normal text-muted-foreground">({page.bookmarks.length})</span></h3>
|
|
<Popover open={linkOpen} onOpenChange={setLinkOpen}>
|
|
<PopoverTrigger asChild><Button size="sm" variant="outline"><Link2 className="mr-1 h-4 w-4" />Add link</Button></PopoverTrigger>
|
|
<PopoverContent align="end" className="w-80 p-2">
|
|
<Input autoFocus value={linkQuery} onChange={(e) => setLinkQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && looksLikeUrl && addUrl()} placeholder="Search bookmarks or paste a URL" className="h-8 text-sm" />
|
|
<div className="mt-2 max-h-64 overflow-y-auto">
|
|
{looksLikeUrl && (
|
|
<button onClick={addUrl} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted"><Plus className="h-3.5 w-3.5" />Save & add “{linkQuery.trim()}”</button>
|
|
)}
|
|
{results.filter((r) => !page.bookmarks.some((x) => x.bookmark.id === r.id)).map((r) => (
|
|
<button key={r.id} onClick={() => addBookmark(r.id)} className="flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-muted">
|
|
<span className="truncate text-sm">{r.title || r.ogTitle || r.url}</span>
|
|
<span className="truncate text-xs text-muted-foreground">{host(r.url)}</span>
|
|
</button>
|
|
))}
|
|
{!looksLikeUrl && results.length === 0 && <p className="px-2 py-3 text-center text-xs text-muted-foreground">No matching bookmarks</p>}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{page.bookmarks.length === 0 ? (
|
|
<div className="rounded-lg bg-muted/40 p-8 text-center text-sm text-muted-foreground">No links yet. Add existing bookmarks or paste a new URL.</div>
|
|
) : (
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{page.bookmarks.map(({ bookmark: b }) => (
|
|
<div key={b.id} className="group relative flex gap-3 rounded-lg bg-card p-3 shadow-sm ring-1 ring-border/50 transition-shadow hover:shadow-md">
|
|
{b.ogImage ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={b.ogImage} alt={`Preview of ${b.title || host(b.url)}`} referrerPolicy="no-referrer" className="h-16 w-24 shrink-0 rounded object-cover bg-muted" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
|
) : null}
|
|
<a href={b.url} target="_blank" rel="noopener noreferrer" className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
{b.favicon && (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={b.favicon} alt="" referrerPolicy="no-referrer" className="h-3.5 w-3.5" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
|
)}
|
|
<span className="truncate text-sm font-medium">{b.title || b.ogTitle || b.url}</span>
|
|
</div>
|
|
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{b.description || b.ogDescription || ''}</p>
|
|
<p className="mt-1 text-[11px] text-muted-foreground/80">{host(b.url)}</p>
|
|
</a>
|
|
<button onClick={() => removeBookmark(b.id)} title="Remove from page" className="absolute right-1.5 top-1.5 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100"><X className="h-3.5 w-3.5" /></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{page.backlinks.length > 0 && (
|
|
<section className="mt-8" aria-label="Pages linking here">
|
|
<h3 className="mb-2 font-display text-lg font-semibold">
|
|
Linked from <span className="text-sm font-normal text-muted-foreground">({page.backlinks.length})</span>
|
|
</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{page.backlinks.map((b) => (
|
|
<button
|
|
key={b.id}
|
|
type="button"
|
|
onClick={() => void select(b.id)}
|
|
className="flex max-w-full items-center gap-1.5 rounded-md border bg-card px-2.5 py-1.5 text-sm transition-colors hover:bg-accent"
|
|
>
|
|
<CornerUpLeft className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">{b.title}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Dialog open={historyOpen} onOpenChange={(open) => { setHistoryOpen(open); if (!open) setRevision(null) }}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader><DialogTitle>Version history</DialogTitle></DialogHeader>
|
|
<p className="text-xs text-muted-foreground">
|
|
Every saved content version is kept. Restoring writes a new version, so nothing in history is ever rewritten.
|
|
</p>
|
|
<div className="grid gap-3 md:grid-cols-[15rem_1fr]">
|
|
<div className="max-h-[24rem] space-y-1 overflow-y-auto rounded-md border p-1">
|
|
{revisions.map((rev) => (
|
|
<div key={rev.id} className={cn('rounded-md px-2 py-1.5', revision?.id === rev.id ? 'bg-accent' : 'hover:bg-muted')}>
|
|
<button type="button" className="flex w-full items-center justify-between gap-2 text-left" onClick={() => void viewRevision(rev.id)}>
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-sm font-medium">Version {rev.version}</span>
|
|
<span className="block text-xs text-muted-foreground">{new Date(rev.createdAt).toLocaleString()}</span>
|
|
</span>
|
|
{revisions[0]?.id === rev.id && <span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">Current</span>}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={historyBusy === rev.id}
|
|
onClick={() => void restoreRevision(rev)}
|
|
className="mt-1 flex items-center gap-1 text-xs text-primary hover:underline disabled:opacity-50"
|
|
>
|
|
<RotateCcw className="h-3 w-3" /> Restore
|
|
</button>
|
|
</div>
|
|
))}
|
|
{revisions.length === 0 && <p className="px-2 py-6 text-center text-xs text-muted-foreground">No saved versions yet.</p>}
|
|
</div>
|
|
<div className="max-h-[24rem] overflow-y-auto rounded-md border bg-muted/20 p-3">
|
|
{revision ? (
|
|
<>
|
|
<p className="mb-2 text-sm font-medium">{revision.title}</p>
|
|
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-muted-foreground">{revision.content || '(empty page)'}</pre>
|
|
</>
|
|
) : (
|
|
<p className="py-10 text-center text-sm text-muted-foreground">Select a version to preview it.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|