Skip to content

Commit 881e283

Browse files
authored
Support snapshot management (#210)
* Introduce shadcn's components * Redesign data flow and implement snapshot management * Fix import file extensions * Fix localStorage data migration * Should use `Decode` instead of `Parse` * Add type check to the test command * Click the new button to renew the notebook * Support renaming snapshots * Run Prettier * Bypass SSR issues using dynamic import * Prevent the pages from being SSR'ed * Show placeholders when loading dynamic components * Display the latest snapshot differently
1 parent f9492a6 commit 881e283

40 files changed

Lines changed: 3791 additions & 397 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ node_modules
33
dist
44
.next
55
out
6-
.vercel
6+
.vercel
7+
*.tsbuildinfo

app/Editor.jsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ export function Editor({
2929
onBeforeEachRun = () => {},
3030
autoRun = true,
3131
toolBarStart = null,
32+
toolBarEnd = null,
3233
pinToolbar = true,
3334
onDuplicate = null,
3435
onError = onDefaultError,
36+
readonly = false,
3537
}) {
3638
const containerRef = useRef(null);
3739
const editorRef = useRef(null);
@@ -40,16 +42,16 @@ export function Editor({
4042
useEffect(() => {
4143
if (containerRef.current) {
4244
containerRef.current.innerHTML = "";
43-
editorRef.current = createEditor(containerRef.current, {code: initialCode, onError});
44-
if (autoRun) onRun();
45+
editorRef.current = createEditor(containerRef.current, {code: initialCode, onError, readonly});
46+
if (autoRun && !readonly) onRun();
4547
}
4648
return () => {
4749
if (editorRef.current) {
4850
editorRef.current.destroy();
4951
}
5052
};
5153
// eslint-disable-next-line react-hooks/exhaustive-deps
52-
}, [initialCode]);
54+
}, [initialCode, readonly]);
5355

5456
useEffect(() => {
5557
const onInput = (code) => {
@@ -95,7 +97,7 @@ export function Editor({
9597
}
9698

9799
return (
98-
<div className={cn("w-full border border-gray-200 rounded-md")}>
100+
<div className={cn("w-full border border-gray-200 rounded-md overflow-hidden")}>
99101
<div
100102
className={cn(
101103
"flex justify-between items-center p-2 border-b border-gray-200 bg-gray-100",
@@ -104,6 +106,7 @@ export function Editor({
104106
>
105107
{toolBarStart}
106108
<div className={cn("flex items-center gap-3")}>
109+
{toolBarEnd}
107110
<button
108111
onClick={onRun}
109112
data-tooltip-id="action-tooltip"

app/EditorPage.jsx

Lines changed: 70 additions & 183 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,47 @@
11
"use client";
2-
import {useState, useEffect, useRef, useCallback, useSyncExternalStore} from "react";
2+
import {useState, useEffect, useRef, useCallback} from "react";
33
import {notFound, useRouter} from "next/navigation";
4-
import {Pencil} from "lucide-react";
4+
import {Camera} from "lucide-react";
55
import {Editor} from "./Editor.jsx";
6-
import {getNotebookById, createNotebook, addNotebook, saveNotebook, getNotebooks, duplicateNotebook} from "./api.js";
7-
import {isDirtyStore, countStore} from "./store.js";
86
import {cn} from "./cn.js";
9-
import {SafeLink} from "./SafeLink.jsx";
107
import {BASE_PATH} from "./shared.js";
11-
12-
const UNSET = Symbol("UNSET");
8+
import {SnapshotsDialog} from "../components/notebooks/SnapshotsDialog.tsx";
9+
import {useNotebook} from "@/lib/notebooks/hooks.ts";
10+
import {NotebookTitle} from "../components/notebooks/NotebookTitle.tsx";
11+
import {EditorPageHero} from "../components/notebooks/EditorPageHero.tsx";
1312

1413
export function EditorPage({id: initialId}) {
1514
const router = useRouter();
16-
const [notebook, setNotebook] = useState(UNSET);
17-
const [notebookList, setNotebookList] = useState([]);
18-
const [showInput, setShowInput] = useState(false);
19-
const [autoRun, setAutoRun] = useState(false);
20-
const [id, setId] = useState(initialId);
21-
const [initialCode, setInitialCode] = useState(null);
22-
const [title, setTitle] = useState("");
23-
const titleRef = useRef(null);
24-
const count = useSyncExternalStore(countStore.subscribe, countStore.getSnapshot, countStore.getServerSnapshot);
25-
const isDirty = useSyncExternalStore(
26-
isDirtyStore.subscribe,
27-
isDirtyStore.getSnapshot,
28-
isDirtyStore.getServerSnapshot,
29-
);
30-
const prevCount = useRef(id ? count : null); // Last saved count.
31-
const isAdded = prevCount.current === count; // Whether the notebook is added to the storage.
15+
const {
16+
notebook,
17+
isDraft,
18+
isDirty,
19+
initialContent,
20+
saveNotebook,
21+
updateContent,
22+
updateTitle,
23+
createSnapshot,
24+
restoreContent,
25+
} = useNotebook(initialId);
26+
const [showSnapshotsDialog, setShowSnapshotsDialog] = useState(false);
3227
const timer = useRef(null);
3328

3429
const onSave = useCallback(() => {
35-
isDirtyStore.setDirty(false);
36-
if (isAdded) {
37-
saveNotebook(notebook);
38-
} else {
39-
addNotebook(notebook);
40-
prevCount.current = count;
41-
const id = notebook.id;
42-
setId(id); // Force re-render.
43-
window.history.pushState(null, "", `${BASE_PATH}/works/${id}`); // Just update the url, no need to reload the page.
44-
}
45-
// eslint-disable-next-line react-hooks/exhaustive-deps
46-
}, [notebook]);
47-
48-
// This effect is triggered when the count changes,
49-
// which happens when user clicks the "New" nav link.
50-
useEffect(() => {
51-
const initialNotebook = isAdded ? getNotebookById(id) : createNotebook();
52-
setNotebook(initialNotebook);
53-
setInitialCode(initialNotebook.content);
54-
setAutoRun(initialNotebook.autoRun);
55-
setTitle(initialNotebook.title);
56-
// eslint-disable-next-line react-hooks/exhaustive-deps
57-
}, [count]);
30+
if (notebook === null) return;
31+
saveNotebook();
32+
// Just update the url, no need to reload the page.
33+
const url = `${BASE_PATH}/works/${notebook.id}`;
34+
window.history.pushState(null, "", url);
35+
}, [notebook, saveNotebook]);
5836

5937
useEffect(() => {
6038
// Use setTimeout to avoid changing to default title.
61-
setTimeout(() => {
62-
document.title = `${isAdded ? notebook.title : "New"} | Recho Notebook`;
63-
}, 100);
64-
}, [notebook, isAdded]);
65-
66-
useEffect(() => {
67-
const notebooks = getNotebooks();
68-
setNotebookList(notebooks.slice(0, 4));
69-
}, [isAdded]);
39+
if (notebook) {
40+
setTimeout(() => {
41+
document.title = `${isDirty ? "* " : ""}${isDraft ? "New" : notebook.title}| Recho Notebook`;
42+
}, 100);
43+
}
44+
}, [notebook, isDirty, isDraft]);
7045

7146
useEffect(() => {
7247
const onBeforeUnload = (e) => {
@@ -84,57 +59,13 @@ export function EditorPage({id: initialId}) {
8459
return () => window.removeEventListener("keydown", onKeyDown);
8560
}, [onSave]);
8661

87-
useEffect(() => {
88-
if (showInput) titleRef.current.focus();
89-
}, [showInput]);
90-
91-
if (notebook === UNSET) return <div className={cn("max-w-screen-lg mx-auto my-10 editor-page")}>Loading...</div>;
92-
9362
if (!notebook) return notFound();
9463

95-
function onUserInput(code) {
96-
const newNotebook = {...notebook, content: code};
97-
if (isAdded) {
98-
saveNotebook(newNotebook);
99-
setNotebook(newNotebook);
100-
} else {
101-
setNotebook(newNotebook);
102-
isDirtyStore.setDirty(true);
103-
}
104-
}
105-
106-
function onRename() {
107-
setShowInput(true);
108-
setTitle(notebook.title);
109-
}
110-
111-
// Only submit rename when blur with valid title.
112-
function onTitleBlur() {
113-
setShowInput(false);
114-
// The title can't be empty.
115-
if (!title) return setTitle(notebook.title);
116-
const newNotebook = {...notebook, title};
117-
setNotebook(newNotebook);
118-
if (isAdded) saveNotebook(newNotebook);
119-
else isDirtyStore.setDirty(true);
120-
}
121-
122-
function onTitleChange(e) {
123-
setTitle(e.target.value);
124-
}
125-
126-
function onTitleKeyDown(e) {
127-
if (e.key === "Enter") {
128-
onTitleBlur();
129-
titleRef.current.blur();
130-
}
131-
}
132-
13364
// If long-running code is detected, set autoRun to false
13465
// to prevent the browser from freezing on infinite loops
13566
// and can't continue to edit the code.
13667
function onBeforeEachRun() {
137-
if (!isAdded) return;
68+
if (isDraft) return;
13869
if (timer.current) clearTimeout(timer.current);
13970
const newNotebook = {...notebook, autoRun: false};
14071
saveNotebook(newNotebook);
@@ -151,98 +82,54 @@ export function EditorPage({id: initialId}) {
15182
router.push(`/works/${duplicated.id}`);
15283
}
15384

85+
function onRestoreSnapshot(snapshot) {
86+
restoreContent(snapshot.content);
87+
}
88+
15489
return (
15590
<div>
156-
{!isAdded && notebookList.length > 0 && (
157-
<div className={cn("flex h-[72px] bg-gray-100 p-2 w-full border-b border-gray-200")}>
158-
<div
159-
className={cn(
160-
"flex items-center justify-between gap-2 h-full max-w-screen-lg lg:mx-auto mx-4 w-full hidden md:flex",
161-
)}
162-
>
163-
{notebookList.map((notebook) => (
164-
<div key={notebook.id} className={cn("flex items-start flex-col gap-1")}>
165-
<SafeLink
166-
href={`/works/${notebook.id}`}
167-
className={cn(
168-
"font-semibold hover:underline text-blue-500 whitespace-nowrap line-clamp-1 max-w-[150px] text-ellipsis",
169-
)}
170-
>
171-
{notebook.title}
172-
</SafeLink>
173-
<span
174-
className={cn("text-xs text-gray-500 line-clamp-1 whitespace-nowrap max-w-[150px] text-ellipsis")}
175-
>
176-
Created {new Date(notebook.created).toLocaleDateString()}
177-
</span>
178-
</div>
179-
))}
180-
<SafeLink href="/works" className={cn("font-semibold text-blue-500 hover:underline")}>
181-
View your notebooks
182-
</SafeLink>
183-
</div>
184-
<div
185-
className={cn(
186-
"flex items-center justify-between gap-2 h-full max-w-screen-lg lg:mx-auto mx-4 w-full md:hidden",
187-
)}
188-
>
189-
<SafeLink
190-
href="/works"
191-
className={cn(
192-
"font-medium w-full border border-gray-300 rounded-md px-3 py-2 text-sm text-center hover:bg-gray-200",
193-
)}
194-
>
195-
View your notebooks
196-
</SafeLink>
197-
</div>
198-
</div>
199-
)}
200-
{!isAdded && notebookList.length === 0 && (
201-
<div className={cn("flex items-center justify-center h-[72px] mt-6 mb-10 lg:mb-0")}>
202-
<p className={cn("text-3xl text-gray-800 font-light text-center mx-10")}>
203-
Explore code and art with instant feedback.
204-
</p>
205-
</div>
206-
)}
91+
<EditorPageHero show={isDraft} />
20792
<div className={cn("max-w-screen-lg lg:mx-auto mx-4 lg:my-10 my-4 editor-page")}>
20893
<Editor
209-
initialCode={initialCode}
94+
initialCode={initialContent}
21095
key={notebook.id}
211-
onUserInput={onUserInput}
96+
onUserInput={updateContent}
21297
onBeforeEachRun={onBeforeEachRun}
213-
autoRun={autoRun}
214-
onDuplicate={isAdded ? onDuplicate : null}
98+
autoRun={notebook.autoRun}
99+
onDuplicate={isDraft ? onDuplicate : null}
215100
toolBarStart={
216-
<div className={cn("flex items-center gap-2")}>
217-
{!isAdded && (
218-
<button
219-
onClick={onSave}
220-
className={cn("bg-green-700 text-white rounded-md px-3 py-1 text-sm hover:bg-green-800")}
221-
>
222-
Create
223-
</button>
224-
)}
225-
{!showInput && isAdded && (
226-
<button onClick={onRename}>
227-
<Pencil className="w-4 h-4" />
228-
</button>
229-
)}
230-
{showInput || !isAdded ? (
231-
<input
232-
type="text"
233-
value={title}
234-
onChange={onTitleChange}
235-
onBlur={onTitleBlur}
236-
onKeyDown={onTitleKeyDown}
237-
ref={titleRef}
238-
className={cn("border border-gray-200 rounded-md px-3 py-1 text-sm bg-white")}
239-
/>
240-
) : (
241-
<span className={cn("text-sm py-1 border border-gray-100 rounded-md")}>{notebook.title}</span>
242-
)}
243-
</div>
101+
<NotebookTitle
102+
title={notebook.title}
103+
setTitle={updateTitle}
104+
isDraft={isDraft}
105+
isDirty={isDirty}
106+
onCreate={onSave}
107+
/>
108+
}
109+
toolBarEnd={
110+
isDraft ? null : (
111+
<button
112+
onClick={() => setShowSnapshotsDialog(true)}
113+
className={cn(
114+
"flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-300 rounded-md hover:bg-gray-200 transition-colors",
115+
)}
116+
data-tooltip-id="action-tooltip"
117+
data-tooltip-content="Manage Snapshots"
118+
data-tooltip-place="bottom"
119+
>
120+
<Camera className={cn("w-4 h-4")} />
121+
<span>Snapshots</span>
122+
</button>
123+
)
244124
}
245125
/>
126+
<SnapshotsDialog
127+
notebook={notebook}
128+
open={showSnapshotsDialog}
129+
createSnapshot={createSnapshot}
130+
onOpenChange={setShowSnapshotsDialog}
131+
onRestore={onRestoreSnapshot}
132+
/>
246133
</div>
247134
</div>
248135
);

app/Nav.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import {usePathname} from "next/navigation";
44
import {useState, useEffect, useRef} from "react";
5-
import {SafeLink} from "./SafeLink.jsx";
5+
import {SafeLink} from "../components/SafeLink.tsx";
66
import {cn} from "./cn.js";
77
import {Plus, Share, Github, Menu, FolderCode} from "lucide-react";
88
import {Tooltip} from "react-tooltip";

app/SafeLink.jsx

Lines changed: 0 additions & 32 deletions
This file was deleted.

0 commit comments

Comments
 (0)