offline sync working partialy
This commit is contained in:
@@ -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) */}
|
||||
@@ -17,8 +20,8 @@ function App() {
|
||||
{/* Protected Routes (only accessible if logged in) */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/archive" element={<DashboardPage />} />
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/archive" element={<DashboardPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
74
k-notes-frontend/src/lib/db.ts
Normal file
74
k-notes-frontend/src/lib/db.ts
Normal 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);
|
||||
}
|
||||
66
k-notes-frontend/src/lib/sync.ts
Normal file
66
k-notes-frontend/src/lib/sync.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user