offline sync working partialy

This commit is contained in:
2025-12-23 16:51:27 +01:00
parent 4535d6fc1c
commit e65567a1a4
8 changed files with 288 additions and 29 deletions

View File

@@ -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,

View File

@@ -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",

View File

@@ -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 (
<Routes>
{/* Public Routes (only accessible if NOT logged in) */}

View File

@@ -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;

View File

@@ -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() {
},
});
}

View File

@@ -50,9 +50,18 @@ 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)
);
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
@@ -78,8 +87,14 @@ async function fetchWithAuth(endpoint: string, options: RequestInit = {}) {
} catch {
return null;
}
} catch (error) {
throw error;
}
}
export const api = {
get: (endpoint: string) => fetchWithAuth(endpoint, { method: "GET" }),
post: (endpoint: string, body: any) =>

View File

@@ -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<import('idb').IDBPDatabase<NotesDB>> | null = null;
export function getDb() {
if (!dbPromise) {
dbPromise = openDB<NotesDB>(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);
}

View File

@@ -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]);
}