diff --git a/.gitignore b/.gitignore index cc239aa..b562531 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules dist .next out -.vercel \ No newline at end of file +.vercel +*.tsbuildinfo diff --git a/app/Editor.jsx b/app/Editor.jsx index e9bfe8b..99ae9eb 100644 --- a/app/Editor.jsx +++ b/app/Editor.jsx @@ -29,9 +29,11 @@ export function Editor({ onBeforeEachRun = () => {}, autoRun = true, toolBarStart = null, + toolBarEnd = null, pinToolbar = true, onDuplicate = null, onError = onDefaultError, + readonly = false, }) { const containerRef = useRef(null); const editorRef = useRef(null); @@ -40,8 +42,8 @@ export function Editor({ useEffect(() => { if (containerRef.current) { containerRef.current.innerHTML = ""; - editorRef.current = createEditor(containerRef.current, {code: initialCode, onError}); - if (autoRun) onRun(); + editorRef.current = createEditor(containerRef.current, {code: initialCode, onError, readonly}); + if (autoRun && !readonly) onRun(); } return () => { if (editorRef.current) { @@ -49,7 +51,7 @@ export function Editor({ } }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialCode]); + }, [initialCode, readonly]); useEffect(() => { const onInput = (code) => { @@ -95,7 +97,7 @@ export function Editor({ } return ( -
+
{toolBarStart}
+ {toolBarEnd} - )} - {!showInput && isAdded && ( - - )} - {showInput || !isAdded ? ( - - ) : ( - {notebook.title} - )} -
+ + } + toolBarEnd={ + isDraft ? null : ( + + ) } /> +
); diff --git a/app/Nav.jsx b/app/Nav.jsx index 62c215c..3f50656 100644 --- a/app/Nav.jsx +++ b/app/Nav.jsx @@ -2,7 +2,7 @@ import {usePathname} from "next/navigation"; import {useState, useEffect, useRef} from "react"; -import {SafeLink} from "./SafeLink.jsx"; +import {SafeLink} from "../components/SafeLink.tsx"; import {cn} from "./cn.js"; import {Plus, Share, Github, Menu, FolderCode} from "lucide-react"; import {Tooltip} from "react-tooltip"; diff --git a/app/SafeLink.jsx b/app/SafeLink.jsx deleted file mode 100644 index 419087f..0000000 --- a/app/SafeLink.jsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; -import {useSyncExternalStore} from "react"; -import {useRouter} from "next/navigation"; -import {isDirtyStore, countStore} from "./store.js"; -import Link from "next/link"; - -export function SafeLink({href, children, className, onClick, ...props}) { - const router = useRouter(); - const isDirty = useSyncExternalStore( - isDirtyStore.subscribe, - isDirtyStore.getSnapshot, - isDirtyStore.getServerSnapshot, - ); - - const handleClick = (e) => { - e.preventDefault(); - if (isDirty) { - const confirmLeave = window.confirm("Your changes will be lost."); - if (!confirmLeave) return; - } - isDirtyStore.setDirty(false); - if (href === "/") countStore.increment(); - router.push(href); - onClick?.(); - }; - - return ( - - {children} - - ); -} diff --git a/app/global.css b/app/global.css index bb327b6..a696110 100644 --- a/app/global.css +++ b/app/global.css @@ -3,6 +3,9 @@ @import "@fontsource-variable/inter"; @import "@fontsource-variable/spline-sans-mono"; @import "@fontsource-variable/spline-sans-mono/wght-italic"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); @theme { --default-font-family: "Inter Variable"; @@ -26,3 +29,122 @@ button { .cm-editor.cm-focused { outline: none !important; } + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.147 0.004 49.25); + --card: oklch(1 0 0); + --card-foreground: oklch(0.147 0.004 49.25); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.147 0.004 49.25); + --primary: oklch(0.216 0.006 56.043); + --primary-foreground: oklch(0.985 0.001 106.423); + --secondary: oklch(0.97 0.001 106.424); + --secondary-foreground: oklch(0.216 0.006 56.043); + --muted: oklch(0.97 0.001 106.424); + --muted-foreground: oklch(0.553 0.013 58.071); + --accent: oklch(0.97 0.001 106.424); + --accent-foreground: oklch(0.216 0.006 56.043); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.923 0.003 48.717); + --input: oklch(0.923 0.003 48.717); + --ring: oklch(0.709 0.01 56.259); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0.001 106.423); + --sidebar-foreground: oklch(0.147 0.004 49.25); + --sidebar-primary: oklch(0.216 0.006 56.043); + --sidebar-primary-foreground: oklch(0.985 0.001 106.423); + --sidebar-accent: oklch(0.97 0.001 106.424); + --sidebar-accent-foreground: oklch(0.216 0.006 56.043); + --sidebar-border: oklch(0.923 0.003 48.717); + --sidebar-ring: oklch(0.709 0.01 56.259); +} + +.dark { + --background: oklch(0.147 0.004 49.25); + --foreground: oklch(0.985 0.001 106.423); + --card: oklch(0.216 0.006 56.043); + --card-foreground: oklch(0.985 0.001 106.423); + --popover: oklch(0.216 0.006 56.043); + --popover-foreground: oklch(0.985 0.001 106.423); + --primary: oklch(0.923 0.003 48.717); + --primary-foreground: oklch(0.216 0.006 56.043); + --secondary: oklch(0.268 0.007 34.298); + --secondary-foreground: oklch(0.985 0.001 106.423); + --muted: oklch(0.268 0.007 34.298); + --muted-foreground: oklch(0.709 0.01 56.259); + --accent: oklch(0.268 0.007 34.298); + --accent-foreground: oklch(0.985 0.001 106.423); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.553 0.013 58.071); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.216 0.006 56.043); + --sidebar-foreground: oklch(0.985 0.001 106.423); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0.001 106.423); + --sidebar-accent: oklch(0.268 0.007 34.298); + --sidebar-accent-foreground: oklch(0.985 0.001 106.423); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.553 0.013 58.071); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/page.jsx b/app/page.jsx index 87879e4..02c11eb 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -1,4 +1,78 @@ -import {EditorPage} from "./EditorPage.jsx"; +"use client"; + +import dynamic from "next/dynamic"; + +const EditorPage = dynamic(() => import("./EditorPage.jsx").then((mod) => ({default: mod.EditorPage})), { + loading: () => , + // As we are reading the notebooks from the localStorage, we don't need to + // render this page on the server side. + ssr: false, +}); + +function LoadingIndicator() { + return ( +
+ {/* Hero section skeleton */} +
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+
+
+
+
+
+ + {/* Editor container skeleton */} +
+ {/* Toolbar skeleton */} +
+
+
+
+
+
+
+
+
+ + {/* Editor area skeleton */} +
+ {/* Code editor header */} +
+
+
+ {/* Code lines skeleton */} +
+
+
+
+
+
+
+
+
+
+
+ + {/* Output area skeleton */} +
+
+
+
+
+
+
+
+
+
+ ); +} export default function Page() { return ; diff --git a/app/store.js b/app/store.js deleted file mode 100644 index a4b72a9..0000000 --- a/app/store.js +++ /dev/null @@ -1,43 +0,0 @@ -function emitChange(listeners) { - for (const listener of listeners) listener(); -} - -let count = 0; -const counterListeners = new Set(); - -export const countStore = { - increment() { - count++; - emitChange(counterListeners); - }, - subscribe(listener) { - counterListeners.add(listener); - return () => counterListeners.delete(listener); - }, - getSnapshot() { - return count; - }, - getServerSnapshot() { - return 0; - }, -}; - -let isDirty = false; -const dirtyListeners = new Set(); - -export const isDirtyStore = { - setDirty(dirty) { - isDirty = dirty; - emitChange(dirtyListeners); - }, - subscribe(listener) { - dirtyListeners.add(listener); - return () => dirtyListeners.delete(listener); - }, - getSnapshot() { - return isDirty; - }, - getServerSnapshot() { - return false; - }, -}; diff --git a/app/works/WorksPage.jsx b/app/works/WorksPage.jsx new file mode 100644 index 0000000..78593ab --- /dev/null +++ b/app/works/WorksPage.jsx @@ -0,0 +1,75 @@ +"use client"; +import {useEffect} from "react"; +import Link from "next/link"; +import {Trash} from "lucide-react"; +import {ThumbnailClient} from "../ThumbnailClient.js"; +import {findFirstOutputRange} from "../shared.js"; +import {cn} from "../cn.js"; +import {useNotebooks} from "@/lib/notebooks/hooks.ts"; + +export function WorksPage() { + const [notebooks, setNotebooks] = useNotebooks(); + const isEmpty = notebooks.length === 0; + + useEffect(() => { + document.title = "Notebooks | Recho Notebook"; + }, []); + + function onDelete(id) { + // deleteNotebook(id); + const newNotebooks = notebooks.filter((notebook) => notebook.id !== id); + setNotebooks(newNotebooks); + } + + if (isEmpty) { + return ( +
+

No notebooks found.

+ + New + +
+ ); + } + + return ( +
+
+ {notebooks.map((notebook) => { + const code = notebook.snapshots[0].content; + return ( +
+
+
+ + {notebook.title} + +
+ Created {new Date(notebook.created).toLocaleDateString()} +
+
+ +
+
+
+ +
+
+
+ ); + })} +
+
+ ); +} diff --git a/app/works/page.jsx b/app/works/page.jsx index 70d5640..8dfdb68 100644 --- a/app/works/page.jsx +++ b/app/works/page.jsx @@ -1,80 +1,41 @@ "use client"; -import {useState, useEffect} from "react"; -import Link from "next/link"; -import {Trash} from "lucide-react"; -import {ThumbnailClient} from "../ThumbnailClient.js"; -import {getNotebooks, deleteNotebook} from "../api.js"; -import {findFirstOutputRange} from "../shared.js"; -import {cn} from "../cn.js"; -export default function Page() { - const [notebooks, setNotebooks] = useState([]); - const isEmpty = notebooks.length === 0; - - useEffect(() => { - const notebooks = getNotebooks(); - setNotebooks(notebooks); - }, []); - - useEffect(() => { - document.title = "Notebooks | Recho Notebook"; - }, []); +import dynamic from "next/dynamic"; - function onDelete(id) { - deleteNotebook(id); - const newNotebooks = notebooks.filter((notebook) => notebook.id !== id); - setNotebooks(newNotebooks); - } +const WorksPage = dynamic(() => import("./WorksPage.jsx").then((mod) => ({default: mod.WorksPage})), { + loading: () => , + // As we are reading the notebooks from the localStorage, we don't need to + // render this page on the server side. + ssr: false, +}); - if (isEmpty) { - return ( -
-

No notebooks found.

- - New - +function SkeletonCard() { + return ( +
+
+
+
+
+
+
- ); - } +
+
+ ); +} +function LoadingIndicator() { return ( -
-
- {notebooks.map((notebook) => ( -
-
-
- - {notebook.title} - -
- Created {new Date(notebook.created).toLocaleDateString()} -
-
- -
-
-
- -
-
-
+
+
+ {[...Array(6)].map((_, i) => ( + ))}
); } + +export default function Page() { + return ; +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..006d007 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/global.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/components/SafeLink.tsx b/components/SafeLink.tsx new file mode 100644 index 0000000..892b15e --- /dev/null +++ b/components/SafeLink.tsx @@ -0,0 +1,33 @@ +"use client"; + +import {useRouter} from "next/navigation"; +import Link, {type LinkProps} from "next/link"; +import {useIsDirty} from "@/lib/notebooks/storage.ts"; + +export type SafeLinkProps = { + href: string; + children: React.ReactNode; + className?: string; + onClick?: () => void; +} & LinkProps; + +export function SafeLink({href, children, className, onClick = () => {}, ...props}: SafeLinkProps) { + const router = useRouter(); + const [isDirty, setIsDirty] = useIsDirty(); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (isDirty && !window.confirm("Your changes will be lost.")) return; + setIsDirty(false); + // If the link is to the new notebook page, we need to renew the notebook. + if (href === "/") window.dispatchEvent(new Event("renew-notebook")); + router.push(href); + onClick?.(); + }; + + return ( + + {children} + + ); +} diff --git a/components/notebooks/EditorPageHero.tsx b/components/notebooks/EditorPageHero.tsx new file mode 100644 index 0000000..4b76663 --- /dev/null +++ b/components/notebooks/EditorPageHero.tsx @@ -0,0 +1,63 @@ +import {useLatestNotebooks} from "@/lib/notebooks/hooks.ts"; +import {SafeLink} from "../SafeLink.tsx"; +import {cn} from "../../app/cn.js"; + +export type EditorPageHeroProps = { + show: boolean; +}; + +export function EditorPageHero({show}: EditorPageHeroProps) { + const latestNotebooks = useLatestNotebooks(4); + if (!show) return null; + if (latestNotebooks.length === 0) { + return ( +
+

+ Explore code and art with instant feedback. +

+
+ ); + } + return ( +
+ +
+ + View your notebooks + +
+
+ ); +} diff --git a/components/notebooks/NotebookTitle.tsx b/components/notebooks/NotebookTitle.tsx new file mode 100644 index 0000000..449d348 --- /dev/null +++ b/components/notebooks/NotebookTitle.tsx @@ -0,0 +1,97 @@ +import {Pencil, AsteriskIcon, CheckIcon} from "lucide-react"; +import {useEffect, useRef, useState} from "react"; + +export type NotebookTitleProps = { + title: string; + setTitle: (notebook: string) => void; + isDraft: boolean; + isDirty: boolean; + onCreate: () => void; +}; + +export function NotebookTitle({title, setTitle, isDraft, isDirty, onCreate}: NotebookTitleProps) { + const [showInput, setShowInput] = useState(false); + const [titleDraft, setTitleDraft] = useState(""); + const titleRef = useRef(null); + + useEffect(() => { + if (showInput) titleRef.current?.focus?.(); + }, [showInput]); + + useEffect(() => { + setTitleDraft(title); + }, [title]); + + // Only submit rename when blur with valid title. + function onTitleBlur() { + setShowInput(false); + // The title can't be empty. + if (!titleDraft) return setTitleDraft(title); + setTitle(titleDraft); + } + + function onTitleChange(e: React.ChangeEvent) { + setTitleDraft(e.target.value); + } + + function onTitleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + onTitleBlur(); + titleRef.current?.blur?.(); + } + } + + function onRename() { + setShowInput(true); + setTitleDraft(title); + } + + return ( +
+ {isDraft && ( + + )} + {!showInput && !isDraft && ( + + )} + {showInput || isDraft ? ( + + ) : ( + {title} + )} + {isDraft ? ( + isDirty ? ( + + ) : null + ) : ( + + )} +
+ ); +} diff --git a/components/notebooks/SnapshotEntry.tsx b/components/notebooks/SnapshotEntry.tsx new file mode 100644 index 0000000..7495630 --- /dev/null +++ b/components/notebooks/SnapshotEntry.tsx @@ -0,0 +1,224 @@ +"use client"; +import {useState, useRef, useEffect} from "react"; +import {Trash2, RotateCcw, Pencil, Check, X, FileCheck} from "lucide-react"; +import {Button} from "../ui/button.tsx"; +import {Input} from "../ui/input.tsx"; +import {cn} from "../../app/cn.js"; +import type {Snapshot} from "@/lib/notebooks/schema.ts"; + +export type SnapshotEntryProps = { + snapshot: Snapshot; + index: number; + isSelected: boolean; + onSelect: (snapshot: Snapshot) => void; + onDelete: (snapshotId: string, e: React.MouseEvent) => void; + onRestore: (snapshotId: string, e: React.MouseEvent) => void; + onRename: (snapshotId: string, newName: string) => void; +}; + +export function SnapshotEntry({ + snapshot, + index, + isSelected, + onSelect, + onDelete, + onRestore, + onRename, +}: SnapshotEntryProps) { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(snapshot.name ?? ""); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + function handleStartEdit(e: React.MouseEvent) { + e.stopPropagation(); + setEditName(snapshot.name ?? ""); + setIsEditing(true); + } + + function handleSave(e?: React.MouseEvent) { + e?.stopPropagation(); + const trimmedName = editName.trim(); + if (trimmedName && trimmedName !== snapshot.name) { + onRename(snapshot.id, trimmedName); + } + setIsEditing(false); + } + + function handleCancel(e?: React.MouseEvent) { + e?.stopPropagation(); + setIsEditing(false); + setEditName(snapshot.name ?? ""); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + handleSave(); + } else if (e.key === "Escape") { + handleCancel(); + } + } + + const isCurrentVersion = index === 0; + const displayName = snapshot.name ?? "Untitled snapshot"; + const canEdit = !isCurrentVersion; // Don't allow editing the current version + + // Current version (first entry) - distinct styling, no name (like git staging area) + if (isCurrentVersion) { + return ( +
onSelect(snapshot)} + > +
+
+
+ + Current +
+ {formatDate(snapshot.created)} +
+
+ +
+
+
+ ); + } + + // Historical snapshots - original styling + return ( +
!isEditing && onSelect(snapshot)} + > +
+
+ {isEditing ? ( +
e.stopPropagation()}> + setEditName(e.target.value)} + onKeyDown={handleKeyDown} + className={cn("h-7 text-sm")} + placeholder="Snapshot name" + /> + + +
+ ) : ( + <> +
+
+ {displayName} +
+ {canEdit && ( + + )} +
+
{formatDate(snapshot.created)}
+ + )} +
+ {!isEditing && ( +
+ + +
+ )} +
+
+ ); +} + +export function formatDate(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + // Create relative time formatter + const rtf = new Intl.RelativeTimeFormat("en", {numeric: "auto"}); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return rtf.format(-diffMins, "minute"); + if (diffHours < 24) return rtf.format(-diffHours, "hour"); + if (diffDays < 7) return rtf.format(-diffDays, "day"); + + // For older dates, use DateTimeFormat + return new Intl.DateTimeFormat("en", { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} diff --git a/components/notebooks/SnapshotsDialog.tsx b/components/notebooks/SnapshotsDialog.tsx new file mode 100644 index 0000000..5702f32 --- /dev/null +++ b/components/notebooks/SnapshotsDialog.tsx @@ -0,0 +1,184 @@ +"use client"; +import {useState, useEffect} from "react"; +import {Camera, RotateCcw} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + // DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog.tsx"; +import {Button} from "../ui/button.tsx"; +import {Input} from "../ui/input.tsx"; +import {cn} from "../../app/cn.js"; +import {Editor} from "../../app/Editor.jsx"; +import {useSnapshots} from "@/lib/notebooks/hooks.ts"; +import type {Notebook, Snapshot} from "@/lib/notebooks/schema.ts"; +import {SnapshotEntry, formatDate} from "./SnapshotEntry.tsx"; + +export type SnapshotsDialogProps = { + notebook: Readonly; + open: boolean; + onOpenChange: (open: boolean) => void; + createSnapshot: (name: string | null) => Snapshot; + onRestore: (snapshot: Snapshot) => void; + deleteSnapshot: (snapshotId: string) => void; +}; + +export function SnapshotsDialog({notebook, open, onOpenChange, createSnapshot, onRestore}: SnapshotsDialogProps) { + const {snapshots, renameSnapshot, deleteSnapshot} = useSnapshots(notebook.id); + const [selectedSnapshot, setSelectedSnapshot] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [snapshotName, setSnapshotName] = useState(""); + + useEffect(() => { + if (open && notebook?.id) { + if (snapshots.length > 0 && !selectedSnapshot) { + setSelectedSnapshot(snapshots[0]); + } + } else { + setSelectedSnapshot(null); + } + }, [open, notebook?.id, snapshots, selectedSnapshot]); + + function handleCreateSnapshot() { + if (!notebook) return; + setIsCreating(true); + setTimeout(() => { + const name = snapshotName.trim() || null; + const snapshot = createSnapshot(name); + setSelectedSnapshot(snapshot); + setSnapshotName(""); + setIsCreating(false); + }, 100); + } + + function handleRestore(snapshotId: string) { + const snapshot = snapshots.find((s) => s.id === snapshotId); + if (snapshot) { + onRestore(snapshot); + onOpenChange(false); + } else { + console.error(`Snapshot with id ${snapshotId} not found`); + } + } + + function handleDelete(snapshotId: string, e: React.MouseEvent) { + e.stopPropagation(); + if (confirm("Are you sure you want to delete this snapshot?")) { + deleteSnapshot(snapshotId); + } + } + + return ( + + + + Snapshots + + Create snapshots to save the current state of your notebook. You can restore any snapshot later. + + +
+ {/* Sidebar */} +
+
+ setSnapshotName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !isCreating && notebook) { + handleCreateSnapshot(); + } + }} + disabled={isCreating || !notebook} + className={cn("h-8 text-sm")} + /> + +
+
+ {snapshots.length === 0 ? ( +
+ +

No snapshots yet. Create your first snapshot to get started.

+
+ ) : ( +
+ {snapshots.map((snapshot, index) => ( + { + e.stopPropagation(); + handleRestore(id); + }} + onRename={renameSnapshot} + /> + ))} +
+ )} +
+
+ + {/* Code Viewer */} +
+ {selectedSnapshot ? ( + <> +
+
+
+ {selectedSnapshot.name ?? "Untitled snapshot"} +
+
{formatDate(selectedSnapshot.created)}
+
+ +
+
+ {}} + /> +
+ + ) : ( +
+
+ +

Select a snapshot to view its code

+
+
+ )} +
+
+
+
+ ); +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..34b924c --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import {Slot} from "@radix-ui/react-slot"; +import {cva, type VariantProps} from "class-variance-authority"; + +import {cn} from "@/lib/utils.ts"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export {Button, buttonVariants}; diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..f273d5a --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,123 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import {XIcon} from "lucide-react"; + +import {cn} from "@/lib/utils.ts"; + +function Dialog({...props}: React.ComponentProps) { + return ; +} + +function DialogTrigger({...props}: React.ComponentProps) { + return ; +} + +function DialogPortal({...props}: React.ComponentProps) { + return ; +} + +function DialogClose({...props}: React.ComponentProps) { + return ; +} + +function DialogOverlay({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({className, ...props}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({className, ...props}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..65738d0 --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import {cn} from "@/lib/utils.ts"; + +function Input({className, type, ...props}: React.ComponentProps<"input">) { + return ( + + ); +} + +export {Input}; diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..6b1bd04 --- /dev/null +++ b/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import {cn} from "@/lib/utils.ts"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export {Separator}; diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 0000000..7381d21 --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,103 @@ +"use client"; + +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import {XIcon} from "lucide-react"; + +import {cn} from "@/lib/utils.ts"; + +function Sheet({...props}: React.ComponentProps) { + return ; +} + +function SheetTrigger({...props}: React.ComponentProps) { + return ; +} + +function SheetClose({...props}: React.ComponentProps) { + return ; +} + +function SheetPortal({...props}: React.ComponentProps) { + return ; +} + +function SheetOverlay({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({className, ...props}: React.ComponentProps<"div">) { + return
; +} + +function SheetFooter({className, ...props}: React.ComponentProps<"div">) { + return
; +} + +function SheetTitle({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({className, ...props}: React.ComponentProps) { + return ( + + ); +} + +export {Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription}; diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx new file mode 100644 index 0000000..93ba0ee --- /dev/null +++ b/components/ui/sidebar.tsx @@ -0,0 +1,673 @@ +"use client"; + +import * as React from "react"; +import {Slot} from "@radix-ui/react-slot"; +import {cva, type VariantProps} from "class-variance-authority"; +import {PanelLeftIcon} from "lucide-react"; + +import {useIsMobile} from "@/hooks/use-mobile.ts"; +import {cn} from "@/lib/utils.ts"; +import {Button} from "@/components/ui/button.tsx"; +import {Input} from "@/components/ui/input.tsx"; +import {Separator} from "@/components/ui/separator.tsx"; +import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet.tsx"; +import {Skeleton} from "@/components/ui/skeleton.tsx"; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +type SidebarContextProps = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +}) { + const {isMobile, state, openMobile, setOpenMobile} = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +} + +function SidebarTrigger({className, onClick, ...props}: React.ComponentProps) { + const {toggleSidebar} = useSidebar(); + + return ( + + ); +} + +function SidebarRail({className, ...props}: React.ComponentProps<"button">) { + const {toggleSidebar} = useSidebar(); + + return ( +