feat(stash): readable offline + export/import backup
The private stash was deny-listed from persistence with no local cache, so an offline or relay-outage cold start showed an EMPTY stash even though decrypting needs no network. - offlineCache v2 adds a 'stash' table storing the encrypted CIPHERTEXT (never the decrypted list). useStash caches it on every successful fetch, and both the read query and fetchLatestStash (the pre-write re-fetch) fall back to it -- so the stash loads at the park and an offline edit rebuilds from the real list instead of throwing. - /stash gains a 'Backup & transfer' card (desktop sidebar + mobile) with Export (a JSON you control) and Import (one re-encrypt, skipping title+category+size duplicates).
This commit is contained in:
parent
8338ec2b33
commit
5a8e3688ee
4 changed files with 239 additions and 28 deletions
13
AGENTS.md
13
AGENTS.md
|
|
@ -782,6 +782,19 @@ session with **no other memory of this project**. So:
|
||||||
while offline→online. ⚠️ `useNostrPublish` now **resolves** (instead of
|
while offline→online. ⚠️ `useNostrPublish` now **resolves** (instead of
|
||||||
throwing) when it queues — callers show their success toast, corrected by a
|
throwing) when it queues — callers show their success toast, corrected by a
|
||||||
global "Saved offline" toast + the badge.
|
global "Saved offline" toast + the badge.
|
||||||
|
- **Stash is readable offline + backed up (2026-09-29).** The private stash
|
||||||
|
(NIP-51 kind `30001`, `d=skatespots-stash`, NIP-44 encrypted to self) was
|
||||||
|
**deny-listed from React Query persistence with NO local cache**, so an
|
||||||
|
offline/relay-outage cold start showed an EMPTY stash even though decryption
|
||||||
|
needs no network. Now the **encrypted CIPHERTEXT** is cached in Dexie
|
||||||
|
(`offlineCache` v2 `stash` table — `cacheStashEvent`/`getCachedStashEvent`),
|
||||||
|
and both the read query and `fetchLatestStash` (the pre-write re-fetch) fall
|
||||||
|
back to it — so the stash still loads at the park and an offline edit rebuilds
|
||||||
|
from the real list instead of throwing. **Only ciphertext is stored, never the
|
||||||
|
decrypted list.** Also added **Export/Import** on `/stash` (`Backup &
|
||||||
|
transfer` card, desktop sidebar + mobile): export writes a JSON you control;
|
||||||
|
import appends in ONE re-encrypt, skipping title+category+size duplicates
|
||||||
|
(`importItems`).
|
||||||
- **Offline map tiles (done 2026-09-28):** `lib/tileCache.ts` (Dexie
|
- **Offline map tiles (done 2026-09-28):** `lib/tileCache.ts` (Dexie
|
||||||
`SkateSpotsTilesDB`, bounded: ≤3000 tiles, ≤500 KB each, oldest-used pruned)
|
`SkateSpotsTilesDB`, bounded: ≤3000 tiles, ≤500 KB each, oldest-used pruned)
|
||||||
+ `lib/cachedTileLayer.ts` (an `L.TileLayer` subclass that reads through it),
|
+ `lib/cachedTileLayer.ts` (an `L.TileLayer` subclass that reads through it),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { useCurrentUser } from './useCurrentUser';
|
import { useCurrentUser } from './useCurrentUser';
|
||||||
import { useNostrPublish } from './useNostrPublish';
|
import { useNostrPublish } from './useNostrPublish';
|
||||||
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
|
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
|
||||||
|
import { cacheStashEvent, getCachedStashEvent } from '@/lib/offlineCache';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
// Stash item stored in encrypted NIP-51 list
|
// Stash item stored in encrypted NIP-51 list
|
||||||
export interface StashItem {
|
export interface StashItem {
|
||||||
|
|
@ -77,32 +79,40 @@ export function useStash(options?: { enabled?: boolean }) {
|
||||||
if (!user?.pubkey || !hasNip44) {
|
if (!user?.pubkey || !hasNip44) {
|
||||||
return { items: [], updatedAt: 0 };
|
return { items: [], updatedAt: 0 };
|
||||||
}
|
}
|
||||||
|
const pubkey = user.pubkey;
|
||||||
|
|
||||||
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
|
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
|
||||||
|
|
||||||
// Fetch the encrypted stash event
|
// Decrypt handle first — an absent/broken signer means "no stash", not a crash.
|
||||||
const events = await nostr.query([{
|
|
||||||
kinds: [STASH_KIND],
|
|
||||||
authors: [user.pubkey],
|
|
||||||
'#d': [STASH_D_TAG],
|
|
||||||
limit: 1,
|
|
||||||
}], { signal: combinedSignal });
|
|
||||||
|
|
||||||
if (events.length === 0) {
|
|
||||||
return { items: [], updatedAt: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = events[0];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Decrypt the content (encrypted to self) — access nip44 inside try/catch
|
|
||||||
// to guard against extension signers throwing when window.nostr is gone
|
|
||||||
let nip44Dec: typeof user.signer.nip44;
|
let nip44Dec: typeof user.signer.nip44;
|
||||||
try { nip44Dec = user.signer?.nip44; } catch { return { items: [], updatedAt: 0 }; }
|
try { nip44Dec = user.signer?.nip44; } catch { return { items: [], updatedAt: 0 }; }
|
||||||
if (!nip44Dec) return { items: [], updatedAt: 0 };
|
if (!nip44Dec) return { items: [], updatedAt: 0 };
|
||||||
const decrypted = await nip44Dec.decrypt(user.pubkey, event.content);
|
|
||||||
const stashData = JSON.parse(decrypted) as Stash;
|
// Prefer the live event; if the relays are unreachable (offline / outage)
|
||||||
return stashData;
|
// fall back to the encrypted copy we cached locally — decrypting needs no
|
||||||
|
// network, so the stash still works at the skatepark.
|
||||||
|
let event: NostrEvent | undefined;
|
||||||
|
try {
|
||||||
|
const events = await nostr.query([{
|
||||||
|
kinds: [STASH_KIND],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [STASH_D_TAG],
|
||||||
|
limit: 1,
|
||||||
|
}], { signal: combinedSignal });
|
||||||
|
event = events[0];
|
||||||
|
} catch {
|
||||||
|
// offline — the cache below covers it
|
||||||
|
}
|
||||||
|
if (!event) event = await getCachedStashEvent(pubkey);
|
||||||
|
if (!event) return { items: [], updatedAt: 0 };
|
||||||
|
|
||||||
|
// Keep the ciphertext for next time.
|
||||||
|
cacheStashEvent(pubkey, event).catch(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decrypt the content (encrypted to self).
|
||||||
|
const decrypted = await nip44Dec.decrypt(pubkey, event.content);
|
||||||
|
return JSON.parse(decrypted) as Stash;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Stash] Failed to decrypt stash:', err);
|
console.error('[Stash] Failed to decrypt stash:', err);
|
||||||
return { items: [], updatedAt: 0 };
|
return { items: [], updatedAt: 0 };
|
||||||
|
|
@ -146,6 +156,11 @@ export function useStash(options?: { enabled?: boolean }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Live event first; fall back to the locally cached ciphertext when the
|
||||||
|
// relays are unreachable (so an offline edit still rebuilds from the real
|
||||||
|
// list instead of throwing / clobbering).
|
||||||
|
let event: NostrEvent | undefined;
|
||||||
try {
|
try {
|
||||||
const events = await nostr.query([{
|
const events = await nostr.query([{
|
||||||
kinds: [STASH_KIND],
|
kinds: [STASH_KIND],
|
||||||
|
|
@ -153,8 +168,13 @@ export function useStash(options?: { enabled?: boolean }) {
|
||||||
'#d': [STASH_D_TAG],
|
'#d': [STASH_D_TAG],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
}], { signal: AbortSignal.timeout(10000) });
|
}], { signal: AbortSignal.timeout(10000) });
|
||||||
|
event = events[0];
|
||||||
|
} catch {
|
||||||
|
// offline — fall through to the cache
|
||||||
|
}
|
||||||
|
if (!event) event = await getCachedStashEvent(user.pubkey);
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (!event) {
|
||||||
// No remote event: safe to start fresh only if we also have nothing cached.
|
// No remote event: safe to start fresh only if we also have nothing cached.
|
||||||
return (
|
return (
|
||||||
queryClient.getQueryData<Stash>(['stash', user.pubkey]) ??
|
queryClient.getQueryData<Stash>(['stash', user.pubkey]) ??
|
||||||
|
|
@ -162,7 +182,8 @@ export function useStash(options?: { enabled?: boolean }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const decrypted = await nip44Dec.decrypt(user.pubkey, events[0].content);
|
cacheStashEvent(user.pubkey, event).catch(() => {});
|
||||||
|
const decrypted = await nip44Dec.decrypt(user.pubkey, event.content);
|
||||||
return JSON.parse(decrypted) as Stash;
|
return JSON.parse(decrypted) as Stash;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// On any failure, prefer cached data over an empty list so we never wipe.
|
// On any failure, prefer cached data over an empty list so we never wipe.
|
||||||
|
|
@ -259,6 +280,34 @@ export function useStash(options?: { enabled?: boolean }) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bulk import (Export/Import) — append items, skipping obvious duplicates, in
|
||||||
|
// ONE re-encrypt + publish rather than N.
|
||||||
|
const importItems = useMutation({
|
||||||
|
mutationFn: async (items: Array<Omit<StashItem, 'id' | 'addedAt'>>) => {
|
||||||
|
const currentStash = await fetchLatestStash();
|
||||||
|
const keyOf = (i: { title: string; category: string; size?: string }) =>
|
||||||
|
`${i.title.trim().toLowerCase()}|${i.category}|${(i.size ?? '').trim().toLowerCase()}`;
|
||||||
|
const have = new Set(currentStash.items.map(keyOf));
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const fresh: StashItem[] = [];
|
||||||
|
for (const raw of items) {
|
||||||
|
if (!raw?.title || !raw?.category) continue;
|
||||||
|
const key = keyOf({ title: raw.title, category: raw.category, size: raw.size });
|
||||||
|
if (have.has(key)) continue;
|
||||||
|
have.add(key);
|
||||||
|
fresh.push({ ...raw, id: crypto.randomUUID(), addedAt: now });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fresh.length === 0) return { added: 0, skipped: items.length };
|
||||||
|
await saveStash({ items: [...currentStash.items, ...fresh], updatedAt: now });
|
||||||
|
return { added: fresh.length, skipped: items.length - fresh.length };
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Stats for RPG display
|
// Stats for RPG display
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
if (!stash) return { total: 0, byCategory: {}, totalValue: 0 };
|
if (!stash) return { total: 0, byCategory: {}, totalValue: 0 };
|
||||||
|
|
@ -287,6 +336,7 @@ export function useStash(options?: { enabled?: boolean }) {
|
||||||
addItem,
|
addItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
removeItem,
|
removeItem,
|
||||||
|
importItems,
|
||||||
isEncryptionAvailable: hasNip44,
|
isEncryptionAvailable: hasNip44,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,23 @@ interface OfflineSettings {
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The encrypted stash event, kept on device so the stash is still readable
|
||||||
|
* offline (and during a relay outage). We deliberately cache the CIPHERTEXT —
|
||||||
|
* exactly what relays already hold — never the decrypted list.
|
||||||
|
*/
|
||||||
|
interface CachedStashEvent {
|
||||||
|
pubkey: string;
|
||||||
|
event: NostrEvent;
|
||||||
|
cachedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Create the Dexie database
|
// Create the Dexie database
|
||||||
class SkateSpotsCacheDB extends Dexie {
|
class SkateSpotsCacheDB extends Dexie {
|
||||||
spots!: Table<CachedSpot, string>;
|
spots!: Table<CachedSpot, string>;
|
||||||
pendingPosts!: Table<PendingPost, string>;
|
pendingPosts!: Table<PendingPost, string>;
|
||||||
settings!: Table<OfflineSettings, string>;
|
settings!: Table<OfflineSettings, string>;
|
||||||
|
stash!: Table<CachedStashEvent, string>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('SkateSpotsCacheDB');
|
super('SkateSpotsCacheDB');
|
||||||
|
|
@ -57,6 +69,10 @@ class SkateSpotsCacheDB extends Dexie {
|
||||||
pendingPosts: 'id, kind, status, createdAt',
|
pendingPosts: 'id, kind, status, createdAt',
|
||||||
settings: 'key',
|
settings: 'key',
|
||||||
});
|
});
|
||||||
|
// v2 adds the encrypted-stash cache (see CachedStashEvent).
|
||||||
|
this.version(2).stores({
|
||||||
|
stash: 'pubkey, cachedAt',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,8 +350,34 @@ export async function getPendingPostsCount(): Promise<number> {
|
||||||
return db.pendingPosts.count();
|
return db.pendingPosts.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Offline Detection ============
|
// ============ Encrypted Stash Cache (offline readability) ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember the encrypted stash event so it can be decrypted offline. Stores the
|
||||||
|
* CIPHERTEXT only — never the decrypted list.
|
||||||
|
*/
|
||||||
|
export async function cacheStashEvent(pubkey: string, event: NostrEvent): Promise<void> {
|
||||||
|
const db = getDB();
|
||||||
|
await db.stash.put({ pubkey, event, cachedAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last encrypted stash event we saw for this pubkey, if any. */
|
||||||
|
export async function getCachedStashEvent(pubkey: string): Promise<NostrEvent | undefined> {
|
||||||
|
const db = getDB();
|
||||||
|
try {
|
||||||
|
const row = await db.stash.get(pubkey);
|
||||||
|
return row?.event;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCachedStash(pubkey: string): Promise<void> {
|
||||||
|
const db = getDB();
|
||||||
|
await db.stash.delete(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Offline Detection ============
|
||||||
/**
|
/**
|
||||||
* Check if the app is currently offline
|
* Check if the app is currently offline
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,11 @@ import {
|
||||||
MapPin,
|
MapPin,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
HardDrive,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// ─── Rarity helpers ──────────────────────────────────────────────────────────
|
// ─── Rarity helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -601,8 +605,104 @@ export default function StashPage() {
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { items, stats, isLoading, addItem, updateItem, removeItem, isEncryptionAvailable } = useStash();
|
const { items, stats, isLoading, addItem, updateItem, removeItem, importItems, isEncryptionAvailable } = useStash();
|
||||||
const { syncItem: syncShowcase, unpublishItem: unpublishShowcase } = useStashShowcaseSync();
|
const { syncItem: syncShowcase, unpublishItem: unpublishShowcase } = useStashShowcaseSync();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ── Backup & transfer: your stash is encrypted on Nostr, but a local copy you
|
||||||
|
// control means a dead relay (or a new account) can't lose your gear list. ──
|
||||||
|
const handleExport = () => {
|
||||||
|
const payload = {
|
||||||
|
app: 'SkateSpots',
|
||||||
|
type: 'stash-export',
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `skatespots-stash-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast({ title: 'Stash exported', description: `${items.length} item${items.length === 1 ? '' : 's'} saved to a file.` });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportFile = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await file.text()) as unknown;
|
||||||
|
const raw = Array.isArray(parsed) ? parsed : (parsed as { items?: unknown })?.items;
|
||||||
|
if (!Array.isArray(raw)) throw new Error('No items found in that file.');
|
||||||
|
const cleaned = raw.filter(
|
||||||
|
(i): i is Omit<StashItem, 'id' | 'addedAt'> =>
|
||||||
|
!!i &&
|
||||||
|
typeof i === 'object' &&
|
||||||
|
typeof (i as { title?: unknown }).title === 'string' &&
|
||||||
|
typeof (i as { category?: unknown }).category === 'string',
|
||||||
|
);
|
||||||
|
if (cleaned.length === 0) throw new Error('No valid items found in that file.');
|
||||||
|
const res = await importItems.mutateAsync(cleaned);
|
||||||
|
toast({
|
||||||
|
title: `Imported ${res.added} item${res.added === 1 ? '' : 's'}`,
|
||||||
|
description: res.skipped > 0 ? `${res.skipped} skipped (already in your stash).` : 'Added to your stash.',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: 'Import failed',
|
||||||
|
description: e instanceof Error ? e.message : 'Could not read that file.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const backupCard = (
|
||||||
|
<div className="space-y-3 rounded-2xl border border-white/6 bg-white/2 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive className="h-4 w-4 text-[#ff6600]" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500">Backup & transfer</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-snug text-gray-500">
|
||||||
|
Your stash is encrypted on Nostr. Keep a copy you control — a dead relay or a new
|
||||||
|
account can't lose your gear list.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 gap-1.5 border-white/15"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" /> Export
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 gap-1.5 border-white/15"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={importItems.isPending}
|
||||||
|
>
|
||||||
|
{importItems.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleImportFile(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// Add: save to encrypted stash, then publish a public event if it's public.
|
// Add: save to encrypted stash, then publish a public event if it's public.
|
||||||
const handleAddItem = (item: Omit<StashItem, 'id' | 'addedAt'>) => {
|
const handleAddItem = (item: Omit<StashItem, 'id' | 'addedAt'>) => {
|
||||||
|
|
@ -1079,6 +1179,9 @@ export default function StashPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Backup & transfer */}
|
||||||
|
{backupCard}
|
||||||
|
|
||||||
{/* How it works */}
|
{/* How it works */}
|
||||||
<div className="rounded-2xl border border-white/6 bg-white/2 p-4">
|
<div className="rounded-2xl border border-white/6 bg-white/2 p-4">
|
||||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-3">How it works</p>
|
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-3">How it works</p>
|
||||||
|
|
@ -1115,6 +1218,9 @@ export default function StashPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Backup & transfer — mobile (the sidebar is desktop-only). */}
|
||||||
|
<div className="lg:hidden mt-4">{backupCard}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile FABs */}
|
{/* Mobile FABs */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue