feat(pwa-offline): Pinia store + IndexedDB; contexto para cache/eliminación; toasts; compatibilidad PWA offline
- Agrega @pinia/nuxt, idb y store central (stores/music.ts) - Cacheo manual desde menú contextual y borrado (TrackContextMenu) - Ícono verde para canciones cacheadas, sin auto-cache al reproducir - Toasts de feedback (stores/toast.ts, ToastContainer) - Fallback offline de listado a IndexedDB; fix MUSIC_DIR absoluto en preview/prod - Ajustes PWA: navigateFallback '/', devOptions, workbox condicional - Estilos y animación del context menu (tema light/dark, blur fuerte) - Correcciones de sintaxis y posicionamiento exacto al cursor
This commit is contained in:
132
stores/music.ts
Normal file
132
stores/music.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { openDB, type IDBPDatabase } from 'idb'
|
||||
|
||||
type Track = {
|
||||
name: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
type DBTrack = {
|
||||
name: string
|
||||
blob: Blob
|
||||
type?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase> | null = null
|
||||
|
||||
async function getDB() {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB('repodructor-music', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('tracks')) {
|
||||
const store = db.createObjectStore('tracks', { keyPath: 'name' })
|
||||
store.createIndex('name', 'name', { unique: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
export const useMusicStore = defineStore('music', {
|
||||
state: () => ({
|
||||
tracks: [] as Track[],
|
||||
cachedNames: new Set<string>(),
|
||||
loading: false,
|
||||
error: null as string | null
|
||||
}),
|
||||
getters: {
|
||||
isCached: (state) => (name: string) => state.cachedNames.has(name)
|
||||
},
|
||||
actions: {
|
||||
async fetchTracks() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const response = await $fetch<{ tracks: Track[] }>('/api/music')
|
||||
this.tracks = response.tracks || []
|
||||
} catch (e: any) {
|
||||
this.error = e?.message || 'Failed to load tracks'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async loadCachedNames() {
|
||||
try {
|
||||
const db = await getDB()
|
||||
const tx = db.transaction('tracks', 'readonly')
|
||||
const store = tx.objectStore('tracks')
|
||||
const all = await store.getAllKeys()
|
||||
this.cachedNames = new Set((all as string[]) || [])
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
async loadCachedTracks(): Promise<Track[]> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
const tx = db.transaction('tracks', 'readonly')
|
||||
const store = tx.objectStore('tracks')
|
||||
const all = (await store.getAll()) as DBTrack[]
|
||||
const names = all.map(t => t.name)
|
||||
this.cachedNames = new Set(names)
|
||||
const list: Track[] = all.map(t => ({ name: t.name, duration: t.duration }))
|
||||
return list
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
async getCachedBlob(name: string): Promise<Blob | null> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
const entry = (await db.get('tracks', name)) as DBTrack | undefined
|
||||
if (entry && entry.blob) {
|
||||
this.cachedNames.add(name)
|
||||
return entry.blob
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
async saveTrackBlob(name: string, blob: Blob, duration?: number) {
|
||||
try {
|
||||
const db = await getDB()
|
||||
const payload: DBTrack = { name, blob, type: blob.type, duration }
|
||||
await db.put('tracks', payload)
|
||||
this.cachedNames.add(name)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCachedTrack(name: string) {
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.delete('tracks', name)
|
||||
this.cachedNames.delete(name)
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
// Fetch from API and store in IndexedDB
|
||||
async cacheByName(name: string, duration?: number): Promise<boolean> {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(name)
|
||||
const response = await fetch(`/api/music/${encodedName}`)
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const blob = await response.blob()
|
||||
await this.saveTrackBlob(name, blob, duration)
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
42
stores/toast.ts
Normal file
42
stores/toast.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info'
|
||||
|
||||
export type Toast = {
|
||||
id: number
|
||||
type: ToastType
|
||||
message: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
let counter = 1
|
||||
|
||||
export const useToastStore = defineStore('toast', {
|
||||
state: () => ({
|
||||
toasts: [] as Toast[]
|
||||
}),
|
||||
actions: {
|
||||
push(message: string, type: ToastType = 'info', timeout = 2500) {
|
||||
const id = counter++
|
||||
const toast: Toast = { id, type, message, timeout }
|
||||
this.toasts.push(toast)
|
||||
if (timeout && timeout > 0) {
|
||||
setTimeout(() => this.remove(id), timeout)
|
||||
}
|
||||
return id
|
||||
},
|
||||
success(message: string, timeout = 2500) {
|
||||
return this.push(message, 'success', timeout)
|
||||
},
|
||||
error(message: string, timeout = 3000) {
|
||||
return this.push(message, 'error', timeout)
|
||||
},
|
||||
info(message: string, timeout = 2500) {
|
||||
return this.push(message, 'info', timeout)
|
||||
},
|
||||
remove(id: number) {
|
||||
this.toasts = this.toasts.filter(t => t.id !== id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user