From 32cd636d5ea231ff7292544b568cd6a8a87b0102 Mon Sep 17 00:00:00 2001 From: onlyonehas Date: Mon, 27 Jul 2026 21:54:25 +0100 Subject: [PATCH 01/11] feat: multi-notebook support with tabs, add/rename/switch notebooks --- pages/index.tsx | 233 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 162 insertions(+), 71 deletions(-) diff --git a/pages/index.tsx b/pages/index.tsx index 3996c25e..49e7b354 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -13,7 +13,7 @@ import { app, database } from "@/pages/_document"; import { GoogleAuthProvider, User, getAuth, signInWithPopup } from "firebase/auth"; import { get, ref, set } from "firebase/database"; import { motion } from "framer-motion"; -import { Calendar, CloudOff, Edit2, Moon, Save, Sun, Trash2 } from "lucide-react"; +import { Calendar, CloudOff, Edit2, Moon, Plus, Save, Sun, Trash2, X } from "lucide-react"; import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import "tailwindcss/tailwind.css"; @@ -32,11 +32,22 @@ food: 250 Variable = prev*2 Total=sum-variable`; +type Notebook = { + id: string; + name: string; + input: string; + output: string | null; +}; + +let nextId = 1; + export default function Index() { const user: User | null = useCustomAuth(); const { calculations, saveCalculations, isLoading } = useCalculations(); - const [input, setInput] = useState(initialInput); - const [output, setOutput] = useState(); + const [notebooks, setNotebooks] = useState([ + { id: String(nextId++), name: "General Expense", input: initialInput, output: null }, + ]); + const [activeNotebookId, setActiveNotebookId] = useState("1"); const [, setSum] = useState(0); const [, setPrev] = useState(0); @@ -44,7 +55,8 @@ export default function Index() { const [singOutModal, toggleSingOutModal] = useState(false); const [clearButtonModal, toggleClearButtonModal] = useState(false); - const [notebookName, setNotebookName] = useState("General Expense"); + const activeNotebook = notebooks.find((n) => n.id === activeNotebookId)!; + const [isEditingName, setIsEditingName] = useState(false); const nameInputRef = useRef(null); @@ -71,17 +83,32 @@ export default function Index() { const [datePickerValue, setDatePickerValue] = useState("1"); const [showNextMonth, setShowNextMonth] = useState(false); + const setActiveInput = (val: string) => + setNotebooks((prev) => prev.map((n) => (n.id === activeNotebookId ? { ...n, input: val } : n))); + const setActiveOutput = (val: string | null) => + setNotebooks((prev) => + prev.map((n) => (n.id === activeNotebookId ? { ...n, output: val } : n)), + ); + const setActiveName = (val: string) => + setNotebooks((prev) => prev.map((n) => (n.id === activeNotebookId ? { ...n, name: val } : n))); + + const input = activeNotebook?.input ?? ""; + const output = activeNotebook?.output ?? null; + const notebookName = activeNotebook?.name ?? ""; + useEffect(() => { if (calculations) { - setInput(calculations.input); - setOutput(calculations.output); + setActiveInput(calculations.input); + setActiveOutput(calculations.output); } }, [calculations]); useEffect(() => { if (!user) { - setInput(initialInput); - setOutput(""); + setNotebooks([ + { id: String(nextId++), name: "General Expense", input: initialInput, output: null }, + ]); + setActiveNotebookId("1"); setDeductionDates({}); localStorage.removeItem("deductionDates"); } @@ -176,7 +203,7 @@ export default function Index() { attachQuillScroll(); const text = (editor.getText() || "").replace(/\n$/, ""); if (text !== input) { - setInput(text); + setActiveInput(text); } requestAnimationFrame(() => highlightSyntax(editor)); }, @@ -272,8 +299,34 @@ export default function Index() { }; const clearButtonCallback = () => { - setInput(""); - setOutput(""); + setActiveInput(""); + setActiveOutput(""); + }; + + const addNotebook = () => { + const id = String(nextId++); + const nb: Notebook = { id, name: `Notebook ${notebooks.length + 1}`, input: "", output: null }; + setNotebooks((prev) => [...prev, nb]); + setActiveNotebookId(id); + quillEditorRef.current = null; + }; + + const removeNotebook = (id: string) => { + if (notebooks.length <= 1) return; + setNotebooks((prev) => prev.filter((n) => n.id !== id)); + if (activeNotebookId === id) { + const idx = notebooks.findIndex((n) => n.id === id); + const next = notebooks[idx - 1] || notebooks[idx + 1]; + setActiveNotebookId(next.id); + quillEditorRef.current = null; + } + }; + + const switchNotebook = (id: string) => { + if (id === activeNotebookId) return; + setIsEditingName(false); + setActiveNotebookId(id); + quillEditorRef.current = null; }; const clearButton = () => { @@ -282,7 +335,7 @@ export default function Index() { const handleInput = useCallback(async () => { if (!input) { - setOutput(""); + setActiveOutput(""); return; } const lines = input.split("\n"); @@ -362,7 +415,7 @@ export default function Index() { customOutput = "-"; } - setOutput(newOutput); + setActiveOutput(newOutput); setSum(keywordValues.tempSum); setPrev(keywordValues.tempPrev); variablesRef.current = { ...variables }; @@ -379,7 +432,7 @@ export default function Index() { const newInput = processedLines.join("\n"); if (newInput !== input) { - setInput(newInput); + setActiveInput(newInput); } }, [input, deductionDates, showNextMonth]); @@ -392,7 +445,7 @@ export default function Index() { }; const handleNotebookNameChange = (event: ChangeEvent) => { - setNotebookName(event.target.value); + setActiveName(event.target.value); }; const handleNotebookNameClick = () => { @@ -445,73 +498,111 @@ export default function Index() {
- {isEditingName ? ( - - ) : ( -

- {notebookName} - - - rename - -

- )} -
- - {!user ? ( - - ) : ( + ))} - )} - {user && ( +
+
- )} + {!user ? ( + + ) : ( + + )} + {user && ( + + )} +
From 25bf17ba1c35ce250e6306d18ab099a11c418e8e Mon Sep 17 00:00:00 2001 From: onlyonehas Date: Mon, 27 Jul 2026 22:28:08 +0100 Subject: [PATCH 02/11] fix: hydration error from localStorage access in useState initializer --- pages/index.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pages/index.tsx b/pages/index.tsx index 49e7b354..b0bef21f 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -72,13 +72,16 @@ export default function Index() { const [deductionDates, setDeductionDates] = useState<{ [name: string]: number; - }>(() => { - if (typeof window !== "undefined") { - const stored = localStorage.getItem("deductionDates"); - return stored ? JSON.parse(stored) : {}; + }>({}); + + useEffect(() => { + const stored = localStorage.getItem("deductionDates"); + if (stored) { + try { + setDeductionDates(JSON.parse(stored)); + } catch {} } - return {}; - }); + }, []); const [showDatePickerFor, setShowDatePickerFor] = useState(null); const [datePickerValue, setDatePickerValue] = useState("1"); const [showNextMonth, setShowNextMonth] = useState(false); From c83e946ceff24d0494a0c51c6f384595c4aca82a Mon Sep 17 00:00:00 2001 From: onlyonehas Date: Mon, 27 Jul 2026 22:34:06 +0100 Subject: [PATCH 03/11] fix: persist all notebooks to database, backward-compat with old schema --- hooks/useCalculations.ts | 53 ++++++++++++++++++++++++++-------------- pages/index.tsx | 17 ++++++------- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/hooks/useCalculations.ts b/hooks/useCalculations.ts index 435d947b..0432b78d 100644 --- a/hooks/useCalculations.ts +++ b/hooks/useCalculations.ts @@ -1,25 +1,31 @@ import { database } from "@/pages/_document"; import { User } from "firebase/auth"; -import { get, ref, set, update } from "firebase/database"; +import { get, ref, set } from "firebase/database"; import { useEffect, useState } from "react"; import { useCustomAuth } from "./useCustomAuth"; -interface Calculation { +export interface Notebook { + id: string; + name: string; input: string; - output: string; + output: string | null; +} + +interface NotebooksData { + notebooks: Notebook[]; + activeNotebookId: string; } interface UseCalculationsResult { - calculations: Calculation | null; - saveCalculations: (input: string, output: string) => void; - getCalculations: () => void; + notebooksData: NotebooksData | null; + saveNotebooks: (notebooks: Notebook[], activeNotebookId: string) => Promise; isLoading: boolean; error: Error | null; } export const useCalculations = (): UseCalculationsResult => { const user: User | null = useCustomAuth(); - const [calculations, setCalculations] = useState(null); + const [notebooksData, setNotebooksData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [calculationsRef, setCalculationsRef] = useState(null); @@ -33,7 +39,7 @@ export const useCalculations = (): UseCalculationsResult => { useEffect(() => { if (!user) { - setCalculations(null); + setNotebooksData(null); setHasFetchedCalculations(false); setCalculationsRef(null); } @@ -41,7 +47,7 @@ export const useCalculations = (): UseCalculationsResult => { useEffect(() => { if (!hasFetchedCalculations && calculationsRef) { - getCalculations(); // Pass the reference here + getCalculations(); setHasFetchedCalculations(true); } }, [hasFetchedCalculations, calculationsRef]); @@ -51,8 +57,23 @@ export const useCalculations = (): UseCalculationsResult => { setIsLoading(true); setError(null); const snapshot = await get(calculationsRef); - const data = snapshot.val() as Calculation; - setCalculations(data); + const data = snapshot.val(); + + if (!data) { + setNotebooksData(null); + return; + } + + if (data.notebooks) { + setNotebooksData(data as NotebooksData); + } else if (data.input !== undefined) { + setNotebooksData({ + notebooks: [ + { id: "1", name: "General Expense", input: data.input, output: data.output ?? null }, + ], + activeNotebookId: "1", + }); + } } catch (error) { setError(error as Error); } finally { @@ -60,17 +81,13 @@ export const useCalculations = (): UseCalculationsResult => { } }; - const saveCalculations = async (input: string, output: string) => { + const saveNotebooks = async (notebooks: Notebook[], activeNotebookId: string) => { try { setIsLoading(true); setError(null); if (calculationsRef) { - if (!calculations) { - await set(calculationsRef, { input, output }); - } else { - await update(calculationsRef, { input, output }); - } + await set(calculationsRef, { notebooks, activeNotebookId }); } } catch (error) { setError(error as Error); @@ -79,5 +96,5 @@ export const useCalculations = (): UseCalculationsResult => { } }; - return { calculations, saveCalculations, getCalculations, isLoading, error }; + return { notebooksData, saveNotebooks, isLoading, error }; }; diff --git a/pages/index.tsx b/pages/index.tsx index b0bef21f..52fbec6f 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -43,7 +43,7 @@ let nextId = 1; export default function Index() { const user: User | null = useCustomAuth(); - const { calculations, saveCalculations, isLoading } = useCalculations(); + const { notebooksData, saveNotebooks, isLoading } = useCalculations(); const [notebooks, setNotebooks] = useState([ { id: String(nextId++), name: "General Expense", input: initialInput, output: null }, ]); @@ -100,11 +100,12 @@ export default function Index() { const notebookName = activeNotebook?.name ?? ""; useEffect(() => { - if (calculations) { - setActiveInput(calculations.input); - setActiveOutput(calculations.output); + if (notebooksData && notebooksData.notebooks.length) { + setNotebooks(notebooksData.notebooks); + setActiveNotebookId(notebooksData.activeNotebookId); + quillEditorRef.current = null; } - }, [calculations]); + }, [notebooksData]); useEffect(() => { if (!user) { @@ -294,10 +295,8 @@ export default function Index() { }; const saveToDatabase = () => { - if (user && input && output) { - if (input !== initialInput && output !== null) { - saveCalculations(input, output); - } + if (user) { + saveNotebooks(notebooks, activeNotebookId); } }; From f9b7170ab450b43ca0e13cefc1f714800867260c Mon Sep 17 00:00:00 2001 From: onlyonehas Date: Mon, 27 Jul 2026 22:40:21 +0100 Subject: [PATCH 04/11] feat: notebook colors (color picker per tab) and export/import JSON --- hooks/useCalculations.ts | 20 +++- pages/index.tsx | 191 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 200 insertions(+), 11 deletions(-) diff --git a/hooks/useCalculations.ts b/hooks/useCalculations.ts index 0432b78d..f65f6b67 100644 --- a/hooks/useCalculations.ts +++ b/hooks/useCalculations.ts @@ -9,8 +9,20 @@ export interface Notebook { name: string; input: string; output: string | null; + color?: string; } +export const NOTEBOOK_COLORS = [ + "#3b82f6", + "#ef4444", + "#22c55e", + "#f59e0b", + "#a855f7", + "#ec4899", + "#14b8a6", + "#f97316", +]; + interface NotebooksData { notebooks: Notebook[]; activeNotebookId: string; @@ -69,7 +81,13 @@ export const useCalculations = (): UseCalculationsResult => { } else if (data.input !== undefined) { setNotebooksData({ notebooks: [ - { id: "1", name: "General Expense", input: data.input, output: data.output ?? null }, + { + id: "1", + name: "General Expense", + input: data.input, + output: data.output ?? null, + color: NOTEBOOK_COLORS[0], + }, ], activeNotebookId: "1", }); diff --git a/pages/index.tsx b/pages/index.tsx index 52fbec6f..287d501e 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -37,15 +37,33 @@ type Notebook = { name: string; input: string; output: string | null; + color?: string; }; +const NOTEBOOK_COLORS = [ + "#3b82f6", + "#ef4444", + "#22c55e", + "#f59e0b", + "#a855f7", + "#ec4899", + "#14b8a6", + "#f97316", +]; + let nextId = 1; export default function Index() { const user: User | null = useCustomAuth(); const { notebooksData, saveNotebooks, isLoading } = useCalculations(); const [notebooks, setNotebooks] = useState([ - { id: String(nextId++), name: "General Expense", input: initialInput, output: null }, + { + id: String(nextId++), + name: "General Expense", + input: initialInput, + output: null, + color: NOTEBOOK_COLORS[0], + }, ]); const [activeNotebookId, setActiveNotebookId] = useState("1"); const [, setSum] = useState(0); @@ -59,6 +77,8 @@ export default function Index() { const [isEditingName, setIsEditingName] = useState(false); const nameInputRef = useRef(null); + const [colorPickerFor, setColorPickerFor] = useState(null); + const colorPickerRef = useRef(null); const inputRef = useRef(null); const outputRef = useRef(null); @@ -70,6 +90,8 @@ export default function Index() { const [scrollTop, setScrollTop] = useState(0); const iconContainerRef = useRef(null); + const importFileRef = useRef(null); + const [deductionDates, setDeductionDates] = useState<{ [name: string]: number; }>({}); @@ -94,6 +116,8 @@ export default function Index() { ); const setActiveName = (val: string) => setNotebooks((prev) => prev.map((n) => (n.id === activeNotebookId ? { ...n, name: val } : n))); + const setNotebookColor = (id: string, color: string) => + setNotebooks((prev) => prev.map((n) => (n.id === id ? { ...n, color } : n))); const input = activeNotebook?.input ?? ""; const output = activeNotebook?.output ?? null; @@ -110,7 +134,13 @@ export default function Index() { useEffect(() => { if (!user) { setNotebooks([ - { id: String(nextId++), name: "General Expense", input: initialInput, output: null }, + { + id: String(nextId++), + name: "General Expense", + input: initialInput, + output: null, + color: NOTEBOOK_COLORS[0], + }, ]); setActiveNotebookId("1"); setDeductionDates({}); @@ -136,6 +166,18 @@ export default function Index() { } }, [isEditingName]); + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) { + setColorPickerFor(null); + } + }; + if (colorPickerFor) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [colorPickerFor]); + const highlightSyntax = useCallback((editor: any) => { if (!editor) return; const text = editor.getText() || ""; @@ -300,6 +342,50 @@ export default function Index() { } }; + const exportNotebooks = () => { + const data = { notebooks, activeNotebookId, exportedAt: new Date().toISOString() }; + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "instant-calc-notebooks.json"; + a.click(); + URL.revokeObjectURL(url); + }; + + const importNotebooks = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (evt) => { + try { + const data = JSON.parse(evt.target?.result as string); + if (!data.notebooks || !Array.isArray(data.notebooks) || !data.notebooks.length) { + alert("Invalid file: no notebooks found."); + return; + } + for (const nb of data.notebooks) { + if (!nb.id || !nb.name || nb.input === undefined) { + alert("Invalid file: notebook missing required fields (id, name, input)."); + return; + } + } + const merged = data.notebooks.map((nb: Notebook) => ({ + ...nb, + color: nb.color || NOTEBOOK_COLORS[0], + })); + setNotebooks(merged); + setActiveNotebookId(data.activeNotebookId || merged[0].id); + quillEditorRef.current = null; + if (user) saveNotebooks(merged, data.activeNotebookId || merged[0].id); + } catch { + alert("Invalid file: could not parse JSON."); + } + }; + reader.readAsText(file); + e.target.value = ""; + }; + const clearButtonCallback = () => { setActiveInput(""); setActiveOutput(""); @@ -307,7 +393,14 @@ export default function Index() { const addNotebook = () => { const id = String(nextId++); - const nb: Notebook = { id, name: `Notebook ${notebooks.length + 1}`, input: "", output: null }; + const color = NOTEBOOK_COLORS[notebooks.length % NOTEBOOK_COLORS.length]; + const nb: Notebook = { + id, + name: `Notebook ${notebooks.length + 1}`, + input: "", + output: null, + color, + }; setNotebooks((prev) => [...prev, nb]); setActiveNotebookId(id); quillEditorRef.current = null; @@ -510,12 +603,7 @@ export default function Index() { {notebooks.map((nb) => (
{ - if (nb.id !== activeNotebookId) { - switchNotebook(nb.id); - } - }} - className={`group flex items-center gap-1 px-2.5 py-1 rounded-md cursor-pointer text-sm whitespace-nowrap transition-colors ${ + className={`group relative flex items-center gap-1 px-2.5 py-1 rounded-md cursor-pointer text-sm whitespace-nowrap transition-colors ${ nb.id === activeNotebookId ? "bg-white dark:bg-gray-700 shadow text-gray-800 dark:text-white font-medium" : "text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50" @@ -533,7 +621,21 @@ export default function Index() { /> ) : ( <> - {nb.name} + { + e.stopPropagation(); + setColorPickerFor((prev) => (prev === nb.id ? null : nb.id)); + }} + /> + { + if (nb.id !== activeNotebookId) switchNotebook(nb.id); + }} + > + {nb.name} + {nb.id === activeNotebookId && ( { @@ -554,6 +656,30 @@ export default function Index() { className="w-3 h-3 text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity" /> )} + {colorPickerFor === nb.id && ( +
e.stopPropagation()} + > + {NOTEBOOK_COLORS.map((c) => ( + { + e.stopPropagation(); + setNotebookColor(nb.id, c); + setColorPickerFor(null); + }} + /> + ))} +
+ )}
))} + + + {!user ? ( +
+ + {!user && ( +
+ Sign in for more features +
+ )} +
- + + + + + {!user && ( +
+ Sign in for more features +
+ )} +
- + + + + + {!user && ( +
+ Sign in for more features +
+ )} + {!user ? (