diff --git a/k-notes-frontend/package-lock.json b/k-notes-frontend/package-lock.json index 2f826d7..d5213b6 100644 --- a/k-notes-frontend/package-lock.json +++ b/k-notes-frontend/package-lock.json @@ -48,6 +48,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "idb": "^8.0.3", "input-otp": "^1.4.2", "lucide-react": "^0.562.0", "next-themes": "^0.4.6", @@ -6621,8 +6622,9 @@ } }, "node_modules/idb": { - "version": "7.1.1", - "dev": true, + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", "license": "ISC" }, "node_modules/ignore": { @@ -10725,6 +10727,13 @@ "workbox-core": "7.4.0" } }, + "node_modules/workbox-background-sync/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, "node_modules/workbox-broadcast-update": { "version": "7.4.0", "dev": true, @@ -10813,6 +10822,13 @@ "workbox-core": "7.4.0" } }, + "node_modules/workbox-expiration/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, "node_modules/workbox-google-analytics": { "version": "7.4.0", "dev": true, diff --git a/k-notes-frontend/package.json b/k-notes-frontend/package.json index 27bbbb9..590f28c 100644 --- a/k-notes-frontend/package.json +++ b/k-notes-frontend/package.json @@ -50,6 +50,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "idb": "^8.0.3", "input-otp": "^1.4.2", "lucide-react": "^0.562.0", "next-themes": "^0.4.6", diff --git a/k-notes-frontend/src/App.tsx b/k-notes-frontend/src/App.tsx index a802762..9006712 100644 --- a/k-notes-frontend/src/App.tsx +++ b/k-notes-frontend/src/App.tsx @@ -4,8 +4,11 @@ import LoginPage from "@/pages/login"; import RegisterPage from "@/pages/register"; import DashboardPage from "@/pages/dashboard"; import Layout from "@/components/layout"; +import { useSync } from "@/lib/sync"; function App() { + useSync(); + return ( {/* Public Routes (only accessible if NOT logged in) */} @@ -17,8 +20,8 @@ function App() { {/* Protected Routes (only accessible if logged in) */} }> }> - } /> - } /> + } /> + } /> diff --git a/k-notes-frontend/src/components/editor/command-list.tsx b/k-notes-frontend/src/components/editor/command-list.tsx index 42fdf09..32c8abb 100644 --- a/k-notes-frontend/src/components/editor/command-list.tsx +++ b/k-notes-frontend/src/components/editor/command-list.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; import { cn } from '@/lib/utils'; -import { Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Type, Quote, Code } from 'lucide-react'; + export interface CommandItemProps { title: string; diff --git a/k-notes-frontend/src/hooks/use-notes.ts b/k-notes-frontend/src/hooks/use-notes.ts index 06a51eb..604ae47 100644 --- a/k-notes-frontend/src/hooks/use-notes.ts +++ b/k-notes-frontend/src/hooks/use-notes.ts @@ -1,5 +1,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "@/lib/api"; +import { addToMutationQueue } from "@/lib/db"; +import { toast } from "sonner"; export interface Note { id: string; @@ -16,6 +18,7 @@ export interface Note { export interface Tag { id: string; name: string; + created_at?: string; } export interface CreateNoteInput { @@ -61,7 +64,44 @@ export function useCreateNote() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: CreateNoteInput) => api.post("/notes", data), + mutationFn: async (data: CreateNoteInput) => { + const queueOffline = async () => { + console.log("Queueing offline creation..."); + await addToMutationQueue({ + type: "POST", + endpoint: "/notes", + body: data, + }); + console.log("Offline creation queued."); + toast.info("Note created offline. Will sync when online."); + return { + id: crypto.randomUUID(), + ...data, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + is_pinned: data.is_pinned || false, + is_archived: false, + tags: [], + color: "default" + }; + }; + + if (!navigator.onLine) { + console.log("Navigator is offline, queueing"); + return queueOffline(); + } + + try { + return await api.post("/notes", data); + } catch (error: any) { + console.error("API Error in createNote:", error); + if (!navigator.onLine || error.name === 'AbortError' || error instanceof TypeError) { + console.log("Falling back to offline queue due to error"); + return queueOffline(); + } + throw error; + } + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); queryClient.invalidateQueries({ queryKey: ["tags"] }); @@ -73,7 +113,30 @@ export function useUpdateNote() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, ...data }: UpdateNoteInput) => api.patch(`/notes/${id}`, data), + mutationFn: async ({ id, ...data }: UpdateNoteInput) => { + const queueOffline = async () => { + await addToMutationQueue({ + type: "PATCH", + endpoint: `/notes/${id}`, + body: data, + }); + toast.info("Note updated offline. Will sync when online."); + return { id, ...data }; + }; + + if (!navigator.onLine) { + return queueOffline(); + } + + try { + return await api.patch(`/notes/${id}`, data); + } catch (error: any) { + if (!navigator.onLine || error.name === 'AbortError' || error instanceof TypeError) { + return queueOffline(); + } + throw error; + } + }, // Optimistic update onMutate: async (updatedNote) => { @@ -118,7 +181,29 @@ export function useDeleteNote() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (id: string) => api.delete(`/notes/${id}`), + mutationFn: async (id: string) => { + const queueOffline = async () => { + await addToMutationQueue({ + type: "DELETE", + endpoint: `/notes/${id}`, + }); + toast.info("Note deleted offline. Will sync when online."); + return { id }; + }; + + if (!navigator.onLine) { + return queueOffline(); + } + + try { + return await api.delete(`/notes/${id}`); + } catch (error: any) { + if (!navigator.onLine || error.name === 'AbortError' || error instanceof TypeError) { + return queueOffline(); + } + throw error; + } + }, // Optimistic delete onMutate: async (deletedId) => { @@ -202,4 +287,3 @@ export function useRenameTag() { }, }); } - diff --git a/k-notes-frontend/src/lib/api.ts b/k-notes-frontend/src/lib/api.ts index c88a844..7191d3c 100644 --- a/k-notes-frontend/src/lib/api.ts +++ b/k-notes-frontend/src/lib/api.ts @@ -50,36 +50,51 @@ async function fetchWithAuth(endpoint: string, options: RequestInit = {}) { ...options, headers, credentials: "include", // Important for cookies! + // signal: controller.signal, // Removing signal, using race instead }; - const response = await fetch(url, config); + try { + const fetchPromise = fetch(url, config); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new TypeError("Network request timed out")), 3000) + ); - if (!response.ok) { - // Try to parse error message - let errorMessage = "An error occurred"; - try { - const errorData = await response.json(); - errorMessage = errorData.error?.message || errorData.message || errorMessage; - } catch { - // failed to parse json + const response = (await Promise.race([fetchPromise, timeoutPromise])) as Response; + // clearTimeout(timeoutId); // Not needed with race logic here (though leaking timer? No, race settles.) + + + if (!response.ok) { + // Try to parse error message + let errorMessage = "An error occurred"; + try { + const errorData = await response.json(); + errorMessage = errorData.error?.message || errorData.message || errorMessage; + } catch { + // failed to parse json + } + + throw new ApiError(response.status, errorMessage); } - throw new ApiError(response.status, errorMessage); - } + // For 204 No Content or empty responses + if (response.status === 204) { + return null; + } - // For 204 No Content or empty responses - if (response.status === 204) { - return null; - } + // Try to parse JSON + try { + return await response.json(); + } catch { + return null; + } + } catch (error) { - // Try to parse JSON - try { - return await response.json(); - } catch { - return null; + throw error; } } + + export const api = { get: (endpoint: string) => fetchWithAuth(endpoint, { method: "GET" }), post: (endpoint: string, body: any) => diff --git a/k-notes-frontend/src/lib/db.ts b/k-notes-frontend/src/lib/db.ts new file mode 100644 index 0000000..b4b9dac --- /dev/null +++ b/k-notes-frontend/src/lib/db.ts @@ -0,0 +1,74 @@ +import { type DBSchema, openDB } from 'idb'; + +interface NotesDB extends DBSchema { + notes: { + key: string; + value: any; + }; + mutation_queue: { + key: number; + value: { + id: number; + type: 'POST' | 'PATCH' | 'DELETE'; + endpoint: string; + body?: any; + timestamp: number; + }; + indexes: { 'by-timestamp': number }; + }; +} + +const DB_NAME = 'k-notes-db'; +const DB_VERSION = 1; + +let dbPromise: Promise> | null = null; + +export function getDb() { + if (!dbPromise) { + dbPromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + // Store for caching notes (optional, if we use manual caching) + if (!db.objectStoreNames.contains('notes')) { + db.createObjectStore('notes', { keyPath: 'id' }); + } + + // Store for offline mutations + if (!db.objectStoreNames.contains('mutation_queue')) { + const store = db.createObjectStore('mutation_queue', { + keyPath: 'id', + autoIncrement: true, + }); + store.createIndex('by-timestamp', 'timestamp'); + } + }, + }).catch((err: any) => { + dbPromise = null; // Reset promise on error so we can retry + throw err; + }); + } + return dbPromise; +} + +export type MutationRequest = { + type: 'POST' | 'PATCH' | 'DELETE'; + endpoint: string; + body?: any; +}; + +export async function addToMutationQueue(mutation: MutationRequest) { + const db = await getDb(); + await db.add('mutation_queue', { + ...mutation, + timestamp: Date.now(), + } as any); +} + +export async function getMutationQueue() { + const db = await getDb(); + return db.getAllFromIndex('mutation_queue', 'by-timestamp'); +} + +export async function clearMutationFromQueue(id: number) { + const db = await getDb(); + await db.delete('mutation_queue', id); +} diff --git a/k-notes-frontend/src/lib/sync.ts b/k-notes-frontend/src/lib/sync.ts new file mode 100644 index 0000000..522ea8f --- /dev/null +++ b/k-notes-frontend/src/lib/sync.ts @@ -0,0 +1,66 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { getMutationQueue, clearMutationFromQueue } from './db'; +import { api } from './api'; +import { toast } from 'sonner'; + +export async function processQueue(queryClient: any) { + if (!navigator.onLine) return; + + const queue = await getMutationQueue(); + + if (queue.length === 0) return; + + console.log(`Processing ${queue.length} offline mutations...`); + const toastId = toast.loading('Syncing offline changes...'); + + for (const mutation of queue) { + try { + if (mutation.type === 'POST') { + await api.post(mutation.endpoint, mutation.body); + } else if (mutation.type === 'PATCH') { + await api.patch(mutation.endpoint, mutation.body); + } else if (mutation.type === 'DELETE') { + await api.delete(mutation.endpoint); + } + + // If we reach here, the request was successful + await clearMutationFromQueue(mutation.id); + + } catch (error) { + console.error('Failed to sync mutation:', mutation, error); + // Decide if we should keep it in queue or remove it. + // For now, if it's a 4xx error (client error), maybe remove it? + // If 5xx or network, keep it. + // Simple strategy: keep it until successful. + } + } + + // Refetch data to ensure consistency + queryClient.invalidateQueries({ queryKey: ['notes'] }); + queryClient.invalidateQueries({ queryKey: ['tags'] }); + + toast.dismiss(toastId); + toast.success('Sync complete'); +} + +export function useSync() { + const queryClient = useQueryClient(); + + useEffect(() => { + // Process queue on mount + processQueue(queryClient); + + // Listen for online status + const handleOnline = () => { + console.log('App is back online, syncing...'); + processQueue(queryClient); + }; + + window.addEventListener('online', handleOnline); + + return () => { + window.removeEventListener('online', handleOnline); + }; + }, [queryClient]); +}