diff --git a/hooks/useCalculations.ts b/hooks/useCalculations.ts index 435d947b..f65f6b67 100644 --- a/hooks/useCalculations.ts +++ b/hooks/useCalculations.ts @@ -1,25 +1,43 @@ 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; + color?: string; +} + +export const NOTEBOOK_COLORS = [ + "#3b82f6", + "#ef4444", + "#22c55e", + "#f59e0b", + "#a855f7", + "#ec4899", + "#14b8a6", + "#f97316", +]; + +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 +51,7 @@ export const useCalculations = (): UseCalculationsResult => { useEffect(() => { if (!user) { - setCalculations(null); + setNotebooksData(null); setHasFetchedCalculations(false); setCalculationsRef(null); } @@ -41,7 +59,7 @@ export const useCalculations = (): UseCalculationsResult => { useEffect(() => { if (!hasFetchedCalculations && calculationsRef) { - getCalculations(); // Pass the reference here + getCalculations(); setHasFetchedCalculations(true); } }, [hasFetchedCalculations, calculationsRef]); @@ -51,8 +69,29 @@ 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, + color: NOTEBOOK_COLORS[0], + }, + ], + activeNotebookId: "1", + }); + } } catch (error) { setError(error as Error); } finally { @@ -60,17 +99,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 +114,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 3996c25e..e6a38aff 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,40 @@ food: 250 Variable = prev*2 Total=sum-variable`; +type Notebook = { + id: string; + 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 { calculations, saveCalculations, isLoading } = useCalculations(); - const [input, setInput] = useState(initialInput); - const [output, setOutput] = useState(); + const { notebooksData, saveNotebooks, isLoading } = useCalculations(); + const [notebooks, setNotebooks] = useState([ + { + id: String(nextId++), + name: "General Expense", + input: initialInput, + output: null, + color: NOTEBOOK_COLORS[0], + }, + ]); + const [activeNotebookId, setActiveNotebookId] = useState("1"); const [, setSum] = useState(0); const [, setPrev] = useState(0); @@ -44,9 +73,12 @@ 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); + const [colorPickerFor, setColorPickerFor] = useState(null); + const colorPickerRef = useRef(null); const inputRef = useRef(null); const outputRef = useRef(null); @@ -58,32 +90,76 @@ export default function Index() { const [scrollTop, setScrollTop] = useState(0); const iconContainerRef = useRef(null); + const importFileRef = useRef(null); + 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); + 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 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; + const notebookName = activeNotebook?.name ?? ""; + const latestInputRef = useRef(input); + latestInputRef.current = input; + useEffect(() => { - if (calculations) { - setInput(calculations.input); - setOutput(calculations.output); + if (notebooksData && notebooksData.notebooks.length) { + setNotebooks(notebooksData.notebooks); + setActiveNotebookId(notebooksData.activeNotebookId); + quillEditorRef.current = null; } - }, [calculations]); + }, [notebooksData]); useEffect(() => { if (!user) { - setInput(initialInput); - setOutput(""); + nextId = 1; + const defaultNotebooks: Notebook[] = [ + { + id: String(nextId++), + name: "General Expense", + input: initialInput, + output: null, + color: NOTEBOOK_COLORS[0], + }, + ]; + setNotebooks(defaultNotebooks); + setActiveNotebookId(defaultNotebooks[0].id); setDeductionDates({}); localStorage.removeItem("deductionDates"); + quillEditorRef.current = null; + setTimeout(() => { + const editor = getQuillEditor(); + if (editor) { + const currentText = (editor.getText() || "").replace(/\n$/, ""); + if (currentText !== latestInputRef.current) { + editor.setText(latestInputRef.current || ""); + } + requestAnimationFrame(() => highlightSyntax(editor)); + } + }, 0); } }, [user]); @@ -105,6 +181,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() || ""; @@ -176,31 +264,36 @@ export default function Index() { attachQuillScroll(); const text = (editor.getText() || "").replace(/\n$/, ""); if (text !== input) { - setInput(text); + setActiveInput(text); } requestAnimationFrame(() => highlightSyntax(editor)); }, [input, highlightSyntax, attachQuillScroll, getQuillEditor], ); + const syncEditor = useCallback(() => { + const editor = getQuillEditor(); + if (!editor) return false; + const currentInput = latestInputRef.current; + const currentText = (editor.getText() || "").replace(/\n$/, ""); + if (currentText !== currentInput) { + editor.setText(currentInput || ""); + } + requestAnimationFrame(() => highlightSyntax(editor)); + attachQuillScroll(); + return true; + }, [highlightSyntax, getQuillEditor, attachQuillScroll]); + useEffect(() => { - let rafId: number; - const trySync = () => { - const editor = getQuillEditor(); - if (!editor) { - rafId = requestAnimationFrame(trySync); - return; - } - const currentText = (editor.getText() || "").replace(/\n$/, ""); - if (currentText !== input) { - editor.setText(input || ""); - } - requestAnimationFrame(() => highlightSyntax(editor)); - attachQuillScroll(); + let rafId = 0; + if (syncEditor()) return; + const retry = () => { + if (syncEditor()) return; + rafId = requestAnimationFrame(retry); }; - trySync(); + rafId = requestAnimationFrame(retry); return () => cancelAnimationFrame(rafId); - }, [input, highlightSyntax, getQuillEditor, attachQuillScroll]); + }, [syncEditor, input]); useEffect(() => { attachQuillScroll(); @@ -264,16 +357,91 @@ export default function Index() { }; const saveToDatabase = () => { - if (user && input && output) { - if (input !== initialInput && output !== null) { - saveCalculations(input, output); - } + if (user) { + saveNotebooks(notebooks, activeNotebookId); } }; + 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 = () => { - setInput(""); - setOutput(""); + setActiveInput(""); + setActiveOutput(""); + }; + + const addNotebook = () => { + const id = String(nextId++); + 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; + }; + + 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 +450,7 @@ export default function Index() { const handleInput = useCallback(async () => { if (!input) { - setOutput(""); + setActiveOutput(""); return; } const lines = input.split("\n"); @@ -362,7 +530,7 @@ export default function Index() { customOutput = "-"; } - setOutput(newOutput); + setActiveOutput(newOutput); setSum(keywordValues.tempSum); setPrev(keywordValues.tempPrev); variablesRef.current = { ...variables }; @@ -379,7 +547,7 @@ export default function Index() { const newInput = processedLines.join("\n"); if (newInput !== input) { - setInput(newInput); + setActiveInput(newInput); } }, [input, deductionDates, showNextMonth]); @@ -392,7 +560,7 @@ export default function Index() { }; const handleNotebookNameChange = (event: ChangeEvent) => { - setNotebookName(event.target.value); + setActiveName(event.target.value); }; const handleNotebookNameClick = () => { @@ -445,73 +613,249 @@ export default function Index() {
- {isEditingName ? ( - - ) : ( -

- {notebookName} - - - rename - -

- )} -
- - {!user ? ( - - ) : ( - - )} - {user && ( + ))} +
+ + {!user && ( +
+ Sign in for more features +
+ )} +
+
+
- )} +
+ + {!user && ( +
+ Sign in for more features +
+ )} +
+ +
+ + {!user && ( +
+ Sign in for more features +
+ )} +
+ {!user ? ( + + ) : ( + + )} + {user && ( + + )} +