forked from mirror/crumbs-original
feat: add offline attachment support with IDB pending store and sync
- Bump IDB version to 2, add pendingAttachments object store with noteId index - Add CRUD helpers: addPendingAttachment, getPendingAttachments, removePendingAttachment - ImageUpload stores blobs in IDB when offline, displays via blob URLs - Show pending sync indicator (RefreshCw icon) on not-yet-uploaded images - Add pushPendingAttachments to sync client, runs after note push - Pending attachments auto-upload on reconnect via existing online event listener Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
791876af5f
commit
7cebf44dcc
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
import { optimizeImage } from '$lib/utils/image-optimize.js';
|
||||
import { browser } from '$app/environment';
|
||||
import { addPendingAttachment, type PendingAttachment } from '$lib/sync/idb.js';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
@@ -36,6 +39,34 @@
|
||||
}
|
||||
optimizing = false;
|
||||
|
||||
// Offline: store in IDB and display via blob URL
|
||||
if (browser && !navigator.onLine) {
|
||||
const tempId = crypto.randomUUID();
|
||||
const pending: PendingAttachment = {
|
||||
id: tempId,
|
||||
noteId,
|
||||
optimized,
|
||||
thumbnail,
|
||||
filename: file.name,
|
||||
mimeType: optimized.type || file.type,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
await addPendingAttachment(pending);
|
||||
|
||||
const blobUrl = URL.createObjectURL(optimized);
|
||||
onUpload({
|
||||
id: tempId,
|
||||
noteId,
|
||||
filename: file.name,
|
||||
mimeType: optimized.type || file.type,
|
||||
size: optimized.size,
|
||||
path: blobUrl,
|
||||
pending: true,
|
||||
createdAt: new Date()
|
||||
} as Attachment & { pending: boolean });
|
||||
continue;
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File([optimized], file.name, { type: optimized.type || file.type }));
|
||||
@@ -74,13 +105,20 @@
|
||||
{#if attachments.length > 0}
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
{#each attachments as attachment}
|
||||
{@const isPending = 'pending' in attachment && attachment.pending}
|
||||
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
|
||||
<div class="group relative">
|
||||
<img
|
||||
src="/api/notes/{noteId}/attachments?attachmentId={attachment.id}"
|
||||
src={imgSrc}
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 rounded-sm object-cover"
|
||||
class="h-20 w-20 rounded-sm object-cover {isPending ? 'opacity-70' : ''}"
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
{#if isPending}
|
||||
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
|
||||
<RefreshCw class="h-3 w-3 text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => onRemove(attachment.id)}
|
||||
class="absolute -right-1 -top-1 hidden rounded-full bg-[var(--destructive)] p-0.5 text-white group-hover:block"
|
||||
|
||||
+27
-1
@@ -1,6 +1,6 @@
|
||||
import { browser } from '$app/environment';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta } from './idb.js';
|
||||
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta, getPendingAttachments, removePendingAttachment } from './idb.js';
|
||||
import { mergeNotes } from './crdt.js';
|
||||
import { loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -49,6 +49,31 @@ async function pushChanges(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push pending offline attachments to server.
|
||||
*/
|
||||
async function pushPendingAttachments(): Promise<void> {
|
||||
const pending = await getPendingAttachments();
|
||||
for (const item of pending) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File([item.optimized], item.filename, { type: item.mimeType }));
|
||||
formData.append('thumbnail', new File([item.thumbnail], `${item.filename}_thumb.webp`, { type: 'image/webp' }));
|
||||
|
||||
const res = await fetch(`/api/notes/${item.noteId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await removePendingAttachment(item.id);
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next sync cycle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull remote changes and merge with local state.
|
||||
*/
|
||||
@@ -98,6 +123,7 @@ export async function sync(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
await pushPendingAttachments();
|
||||
await pullChanges();
|
||||
setStatus('synced');
|
||||
|
||||
|
||||
+43
-1
@@ -2,7 +2,17 @@ import { openDB, type IDBPDatabase } from 'idb';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
const DB_NAME = 'crumbs';
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
noteId: string;
|
||||
optimized: Blob;
|
||||
thumbnail: Blob;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface CrumbsDB {
|
||||
notes: {
|
||||
@@ -23,6 +33,13 @@ interface CrumbsDB {
|
||||
key: string;
|
||||
value: { key: string; value: string };
|
||||
};
|
||||
pendingAttachments: {
|
||||
key: string;
|
||||
value: PendingAttachment;
|
||||
indexes: {
|
||||
'by-noteId': string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SyncQueueItem {
|
||||
@@ -58,6 +75,12 @@ export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
|
||||
if (!db.objectStoreNames.contains('meta')) {
|
||||
db.createObjectStore('meta', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
// Pending attachments (offline uploads)
|
||||
if (!db.objectStoreNames.contains('pendingAttachments')) {
|
||||
const pendingStore = db.createObjectStore('pendingAttachments', { keyPath: 'id' });
|
||||
pendingStore.createIndex('by-noteId', 'noteId');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -122,3 +145,22 @@ export async function setMeta(key: string, value: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('meta', { key, value });
|
||||
}
|
||||
|
||||
// Pending attachment operations
|
||||
export async function addPendingAttachment(item: PendingAttachment): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('pendingAttachments', item);
|
||||
}
|
||||
|
||||
export async function getPendingAttachments(noteId?: string): Promise<PendingAttachment[]> {
|
||||
const db = await getDb();
|
||||
if (noteId) {
|
||||
return db.getAllFromIndex('pendingAttachments', 'by-noteId', noteId);
|
||||
}
|
||||
return db.getAll('pendingAttachments');
|
||||
}
|
||||
|
||||
export async function removePendingAttachment(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete('pendingAttachments', id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user