offline sync working partialy
This commit is contained in:
20
k-notes-frontend/package-lock.json
generated
20
k-notes-frontend/package-lock.json
generated
@@ -48,6 +48,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -6621,8 +6622,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/idb": {
|
"node_modules/idb": {
|
||||||
"version": "7.1.1",
|
"version": "8.0.3",
|
||||||
"dev": true,
|
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
@@ -10725,6 +10727,13 @@
|
|||||||
"workbox-core": "7.4.0"
|
"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": {
|
"node_modules/workbox-broadcast-update": {
|
||||||
"version": "7.4.0",
|
"version": "7.4.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -10813,6 +10822,13 @@
|
|||||||
"workbox-core": "7.4.0"
|
"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": {
|
"node_modules/workbox-google-analytics": {
|
||||||
"version": "7.4.0",
|
"version": "7.4.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import LoginPage from "@/pages/login";
|
|||||||
import RegisterPage from "@/pages/register";
|
import RegisterPage from "@/pages/register";
|
||||||
import DashboardPage from "@/pages/dashboard";
|
import DashboardPage from "@/pages/dashboard";
|
||||||
import Layout from "@/components/layout";
|
import Layout from "@/components/layout";
|
||||||
|
import { useSync } from "@/lib/sync";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
useSync();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public Routes (only accessible if NOT logged in) */}
|
{/* Public Routes (only accessible if NOT logged in) */}
|
||||||
@@ -17,8 +20,8 @@ function App() {
|
|||||||
{/* Protected Routes (only accessible if logged in) */}
|
{/* Protected Routes (only accessible if logged in) */}
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/archive" element={<DashboardPage />} />
|
<Route path="/archive" element={<DashboardPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Type, Quote, Code } from 'lucide-react';
|
|
||||||
|
|
||||||
export interface CommandItemProps {
|
export interface CommandItemProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { addToMutationQueue } from "@/lib/db";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +18,7 @@ export interface Note {
|
|||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateNoteInput {
|
export interface CreateNoteInput {
|
||||||
@@ -61,7 +64,44 @@ export function useCreateNote() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
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: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||||
@@ -73,7 +113,30 @@ export function useUpdateNote() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
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
|
// Optimistic update
|
||||||
onMutate: async (updatedNote) => {
|
onMutate: async (updatedNote) => {
|
||||||
@@ -118,7 +181,29 @@ export function useDeleteNote() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
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
|
// Optimistic delete
|
||||||
onMutate: async (deletedId) => {
|
onMutate: async (deletedId) => {
|
||||||
@@ -202,4 +287,3 @@ export function useRenameTag() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,36 +50,51 @@ async function fetchWithAuth(endpoint: string, options: RequestInit = {}) {
|
|||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
credentials: "include", // Important for cookies!
|
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) {
|
const response = (await Promise.race([fetchPromise, timeoutPromise])) as Response;
|
||||||
// Try to parse error message
|
// clearTimeout(timeoutId); // Not needed with race logic here (though leaking timer? No, race settles.)
|
||||||
let errorMessage = "An error occurred";
|
|
||||||
try {
|
|
||||||
const errorData = await response.json();
|
if (!response.ok) {
|
||||||
errorMessage = errorData.error?.message || errorData.message || errorMessage;
|
// Try to parse error message
|
||||||
} catch {
|
let errorMessage = "An error occurred";
|
||||||
// failed to parse json
|
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
|
// Try to parse JSON
|
||||||
if (response.status === 204) {
|
try {
|
||||||
return null;
|
return await response.json();
|
||||||
}
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
|
||||||
// Try to parse JSON
|
throw error;
|
||||||
try {
|
|
||||||
return await response.json();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: (endpoint: string) => fetchWithAuth(endpoint, { method: "GET" }),
|
get: (endpoint: string) => fetchWithAuth(endpoint, { method: "GET" }),
|
||||||
post: (endpoint: string, body: any) =>
|
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