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:
BIN
Untitled.png
BIN
Untitled.png
Binary file not shown.
|
Before Width: | Height: | Size: 229 KiB |
82
components/ToastContainer.client.vue
Normal file
82
components/ToastContainer.client.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="toast-container">
|
||||||
|
<transition-group name="toast" tag="div">
|
||||||
|
<div
|
||||||
|
v-for="t in toasts"
|
||||||
|
:key="t.id"
|
||||||
|
class="toast glass"
|
||||||
|
:class="t.type"
|
||||||
|
@click="dismiss(t.id)"
|
||||||
|
>
|
||||||
|
<div class="dot" />
|
||||||
|
<span class="msg">{{ t.message }}</span>
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useToastStore } from '~/stores/toast'
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
const { toasts } = storeToRefs(toast)
|
||||||
|
const dismiss = (id: number) => toast.remove(id)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-active, .toast-leave-active {
|
||||||
|
transition: all 250ms ease;
|
||||||
|
}
|
||||||
|
.toast-enter-from, .toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(17, 24, 39, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass { /* keep naming consistent with app */
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success .dot { background: #22c55e; }
|
||||||
|
.toast.error .dot { background: #ef4444; }
|
||||||
|
.toast.info .dot { background: #60a5fa; }
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
153
components/TrackContextMenu.client.vue
Normal file
153
components/TrackContextMenu.client.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<transition name="ctx">
|
||||||
|
<div
|
||||||
|
v-if="visible && track"
|
||||||
|
class="context-menu glass"
|
||||||
|
:style="menuStyle"
|
||||||
|
@contextmenu.prevent
|
||||||
|
>
|
||||||
|
<div class="menu-header">
|
||||||
|
<span class="title" :title="track.name">{{ track.name }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!isCached"
|
||||||
|
class="menu-item"
|
||||||
|
@click.stop="onCache"
|
||||||
|
>
|
||||||
|
<Download :size="18" />
|
||||||
|
<span>Guardar offline</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="menu-item danger"
|
||||||
|
@click.stop="onDelete"
|
||||||
|
>
|
||||||
|
<Trash2 :size="18" />
|
||||||
|
<span>Eliminar de memoria</span>
|
||||||
|
</button>
|
||||||
|
<button class="menu-item" @click.stop="$emit('close')">
|
||||||
|
<X :size="18" />
|
||||||
|
<span>Cerrar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { Download, Trash2, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
x: { type: Number, default: 0 },
|
||||||
|
y: { type: Number, default: 0 },
|
||||||
|
track: { type: Object, default: null },
|
||||||
|
isCached: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'cache', 'delete'])
|
||||||
|
|
||||||
|
const menuStyle = computed(() => {
|
||||||
|
const offset = 0 // show menu as close as possible
|
||||||
|
const padding = 8
|
||||||
|
const width = 260
|
||||||
|
const height = 140
|
||||||
|
let left = props.x + offset
|
||||||
|
let top = props.y + offset
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (left + width + padding > window.innerWidth) {
|
||||||
|
left = Math.max(padding, window.innerWidth - width - padding)
|
||||||
|
}
|
||||||
|
if (top + height + padding > window.innerHeight) {
|
||||||
|
top = Math.max(padding, window.innerHeight - height - padding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { left: left + 'px', top: top + 'px' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const onCache = () => emit('cache', props.track)
|
||||||
|
const onDelete = () => emit('delete', props.track)
|
||||||
|
|
||||||
|
const hide = (e) => {
|
||||||
|
// Close when clicking outside
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('click', hide)
|
||||||
|
window.addEventListener('scroll', hide, true)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('click', hide)
|
||||||
|
window.removeEventListener('scroll', hide, true)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 320px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backdrop-filter: blur(50px);
|
||||||
|
-webkit-backdrop-filter: blur(50px);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-glass);
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Appear/disappear animation */
|
||||||
|
.ctx-enter-active, .ctx-leave-active {
|
||||||
|
transition: opacity 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.ctx-enter-from, .ctx-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-header {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, transform 0.06s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background: var(--bg-glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:active {
|
||||||
|
transform: scale(0.995);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item.danger svg,
|
||||||
|
.menu-item.danger span {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -30,17 +30,33 @@
|
|||||||
:is-playing="currentTrack?.name === track.name && isPlaying"
|
:is-playing="currentTrack?.name === track.name && isPlaying"
|
||||||
:is-loading="loadingTrack === track.name"
|
:is-loading="loadingTrack === track.name"
|
||||||
:has-error="failedTracks.has(track.name)"
|
:has-error="failedTracks.has(track.name)"
|
||||||
|
:is-cached="isTrackCached(track)"
|
||||||
@click="handleTrackClick(track, index)"
|
@click="handleTrackClick(track, index)"
|
||||||
|
@context="handleContextFromItem"
|
||||||
class="track-item-wrapper animate-fade-in-up"
|
class="track-item-wrapper animate-fade-in-up"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<TrackContextMenu
|
||||||
|
:visible="showMenu"
|
||||||
|
:x="menuX"
|
||||||
|
:y="menuY"
|
||||||
|
:track="menuTrack"
|
||||||
|
:is-cached="menuTrack ? isTrackCached(menuTrack) : false"
|
||||||
|
@close="closeMenu"
|
||||||
|
@cache="cacheSelected"
|
||||||
|
@delete="deleteSelected"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
import { Music } from 'lucide-vue-next'
|
import { Music } from 'lucide-vue-next'
|
||||||
import TrackListItem from './TrackListItem.client.vue'
|
import TrackListItem from './TrackListItem.client.vue'
|
||||||
|
import { useMusicStore } from '~/stores/music'
|
||||||
|
import TrackContextMenu from './TrackContextMenu.client.vue'
|
||||||
|
import { useToastStore } from '~/stores/toast'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
tracks: {
|
tracks: {
|
||||||
@@ -78,6 +94,43 @@ const emit = defineEmits(['track-selected'])
|
|||||||
const handleTrackClick = (track, index) => {
|
const handleTrackClick = (track, index) => {
|
||||||
emit('track-selected', { track, index })
|
emit('track-selected', { track, index })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const musicStore = useMusicStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
const isTrackCached = (track) => musicStore.isCached(track.name)
|
||||||
|
|
||||||
|
// Context menu state and handlers
|
||||||
|
const showMenu = ref(false)
|
||||||
|
const menuX = ref(0)
|
||||||
|
const menuY = ref(0)
|
||||||
|
const menuTrack = ref(null)
|
||||||
|
|
||||||
|
const handleContextFromItem = ({ track, x, y }) => {
|
||||||
|
menuTrack.value = track
|
||||||
|
menuX.value = x
|
||||||
|
menuY.value = y
|
||||||
|
showMenu.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMenu = () => { showMenu.value = false }
|
||||||
|
|
||||||
|
const cacheSelected = async () => {
|
||||||
|
if (!menuTrack.value) return
|
||||||
|
const ok = await musicStore.cacheByName(menuTrack.value.name, menuTrack.value.duration)
|
||||||
|
if (ok) {
|
||||||
|
toast.success(`Guardado offline: ${menuTrack.value.name}`)
|
||||||
|
} else {
|
||||||
|
toast.error(`No se pudo guardar: ${menuTrack.value.name}`)
|
||||||
|
}
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSelected = async () => {
|
||||||
|
if (!menuTrack.value) return
|
||||||
|
await musicStore.deleteCachedTrack(menuTrack.value.name)
|
||||||
|
toast.info(`Eliminado de memoria: ${menuTrack.value.name}`)
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
@mouseenter="isHovered = true"
|
@mouseenter="isHovered = true"
|
||||||
@mouseleave="isHovered = false"
|
@mouseleave="isHovered = false"
|
||||||
|
@contextmenu.prevent="handleContext"
|
||||||
>
|
>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<p class="track-name">{{ track.name }}</p>
|
<p class="track-name">{{ track.name }}</p>
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
<AlertCircle v-if="hasError" class="icon error" :size="18" />
|
<AlertCircle v-if="hasError" class="icon error" :size="18" />
|
||||||
<Pause v-else-if="isActive && !isPlaying" class="icon paused" :size="18" />
|
<Pause v-else-if="isActive && !isPlaying" class="icon paused" :size="18" />
|
||||||
<Play v-else-if="isActive && isPlaying" class="icon playing" :size="18" />
|
<Play v-else-if="isActive && isPlaying" class="icon playing" :size="18" />
|
||||||
<Music v-else class="icon idle" :size="18" />
|
<Music v-else :class="['icon', 'idle', { cached: isCached }]" :size="18" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,10 +61,14 @@ const props = defineProps({
|
|||||||
hasError: {
|
hasError: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
isCached: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['click'])
|
const emit = defineEmits(['click', 'context'])
|
||||||
|
|
||||||
const isHovered = ref(false)
|
const isHovered = ref(false)
|
||||||
|
|
||||||
@@ -71,6 +76,10 @@ const handleClick = () => {
|
|||||||
emit('click', props.track)
|
emit('click', props.track)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleContext = (event) => {
|
||||||
|
emit('context', { track: props.track, x: event.clientX, y: event.clientY })
|
||||||
|
}
|
||||||
|
|
||||||
const formatTime = (seconds) => {
|
const formatTime = (seconds) => {
|
||||||
if (!seconds || isNaN(seconds)) return '0:00'
|
if (!seconds || isNaN(seconds)) return '0:00'
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60)
|
||||||
@@ -215,6 +224,10 @@ const formatTime = (seconds) => {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon.idle.cached {
|
||||||
|
color: #22c55e; /* green-500 */
|
||||||
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -284,4 +297,4 @@ const formatTime = (seconds) => {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<slot />
|
<slot />
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import ToastContainer from '~/components/ToastContainer.client.vue'
|
||||||
useHead({
|
useHead({
|
||||||
title: 'RepoDructor - Music Player',
|
title: 'RepoDructor - Music Player',
|
||||||
meta: [
|
meta: [
|
||||||
@@ -24,4 +26,4 @@ useHead({
|
|||||||
{ rel: 'manifest', href: '/manifest.webmanifest' }
|
{ rel: 'manifest', href: '/manifest.webmanifest' }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
import { defineNuxtConfig } from 'nuxt/config'
|
import { defineNuxtConfig } from 'nuxt/config'
|
||||||
|
import { resolve, isAbsolute } from 'path'
|
||||||
|
|
||||||
|
// Compute absolute music directory at build-time to avoid .output cwd issues in preview/prod
|
||||||
|
const musicDirEnv = process.env.MUSIC_DIR || './music'
|
||||||
|
const musicDirAbs = isAbsolute(musicDirEnv) ? musicDirEnv : resolve(process.cwd(), musicDirEnv)
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
|
// Helpers
|
||||||
|
hooks: {},
|
||||||
compatibilityDate: '2025-08-02',
|
compatibilityDate: '2025-08-02',
|
||||||
// Disable SSR completely to avoid hydration issues with client-side audio APIs
|
// Disable SSR completely to avoid hydration issues with client-side audio APIs
|
||||||
ssr: false,
|
ssr: false,
|
||||||
@@ -36,29 +43,20 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
'@vueuse/nuxt',
|
'@vueuse/nuxt',
|
||||||
|
'@pinia/nuxt',
|
||||||
['@vite-pwa/nuxt', {
|
['@vite-pwa/nuxt', {
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['favicon.ico', 'logo.png', 'logo-192.png', 'logo-512.png', 'logo-maskable-512.png', 'icon.svg'],
|
includeAssets: ['favicon.ico', 'logo.png', 'logo-192.png', 'logo-512.png', 'logo-maskable-512.png', 'icon.svg'],
|
||||||
workbox: {
|
workbox: process.env.NODE_ENV === 'production' ? {
|
||||||
navigateFallback: '/',
|
navigateFallback: '/',
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
globPatterns: [
|
globPatterns: [
|
||||||
'**/*.{js,css,html,ico,png,svg}',
|
'**/*.{js,css,html,ico,png,svg}',
|
||||||
'_nuxt/**/*.{js,css}',
|
'_nuxt/**/*.{js,css}',
|
||||||
'assets/**/*.{png,jpg,jpeg,svg,gif,webp}'
|
'assets/**/*.{png,jpg,jpeg,svg,gif,webp}'
|
||||||
],
|
],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
|
||||||
urlPattern: /^https?:\/\/.*\/api\/music\/.*/i,
|
|
||||||
handler: 'CacheFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'music-cache',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 100,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
urlPattern: /\.(png|jpg|jpeg|svg|gif|webp)$/,
|
urlPattern: /\.(png|jpg|jpeg|svg|gif|webp)$/,
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
@@ -71,17 +69,22 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
} : {
|
||||||
|
// Dev SW: configuración mínima y sin patrones problemáticos
|
||||||
|
navigateFallback: '/',
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||||
|
globIgnores: ['**/_payload.json', '_nuxt/builds/**/*.json'],
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
installPrompt: true,
|
installPrompt: true,
|
||||||
periodicSyncForUpdates: 20
|
periodicSyncForUpdates: 20
|
||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: false,
|
// Permite instalar SW en dev cuando se habilita explícitamente
|
||||||
|
enabled: process.env.PWA_DEV === 'true',
|
||||||
type: 'module',
|
type: 'module',
|
||||||
navigateFallback: '/'
|
navigateFallback: '/'
|
||||||
},
|
},
|
||||||
mode: 'development',
|
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'RepoDructor Music Player',
|
name: 'RepoDructor Music Player',
|
||||||
short_name: 'RepoDructor',
|
short_name: 'RepoDructor',
|
||||||
@@ -131,12 +134,17 @@ export default defineNuxtConfig({
|
|||||||
experimental: {
|
experimental: {
|
||||||
wasm: true
|
wasm: true
|
||||||
},
|
},
|
||||||
|
prerender: {
|
||||||
|
crawlLinks: true,
|
||||||
|
routes: ['/']
|
||||||
|
},
|
||||||
// Development configuration for proxy
|
// Development configuration for proxy
|
||||||
devProxy: process.env.NODE_ENV === 'development' ? {} : undefined
|
devProxy: process.env.NODE_ENV === 'development' ? {} : undefined
|
||||||
},
|
},
|
||||||
|
|
||||||
// Runtime configuration
|
// Runtime configuration
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
|
musicDirAbs,
|
||||||
public: {
|
public: {
|
||||||
musicPath: process.env.NUXT_PUBLIC_MUSIC_PATH || '/music'
|
musicPath: process.env.NUXT_PUBLIC_MUSIC_PATH || '/music'
|
||||||
}
|
}
|
||||||
|
|||||||
68
package-lock.json
generated
68
package-lock.json
generated
@@ -9,10 +9,13 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@vite-pwa/nuxt": "^1.0.4",
|
"@vite-pwa/nuxt": "^1.0.4",
|
||||||
"@vueuse/core": "^10.5.0",
|
"@vueuse/core": "^10.5.0",
|
||||||
"@vueuse/nuxt": "^10.5.0",
|
"@vueuse/nuxt": "^10.5.0",
|
||||||
"lucide-vue-next": "^0.536.0"
|
"idb": "^8.0.3",
|
||||||
|
"lucide-vue-next": "^0.536.0",
|
||||||
|
"pinia": "^3.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/devtools": "latest",
|
"@nuxt/devtools": "latest",
|
||||||
@@ -4202,6 +4205,21 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pinia/nuxt": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-CgvSWpbktxxWBV7ModhAcsExsQZqpPq6vMYEe9DexmmY6959ev8ukL4iFhr/qov2Nb9cQAWd7niFDnaWkN+FHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/kit": "^3.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pinia": "^3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -8770,9 +8788,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/idb": {
|
"node_modules/idb": {
|
||||||
"version": "7.1.1",
|
"version": "8.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
@@ -11251,6 +11269,36 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pinia": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-api": "^7.7.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.4.4",
|
||||||
|
"vue": "^2.7.0 || ^3.5.11"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||||
|
"version": "7.7.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz",
|
||||||
|
"integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-kit": "^7.7.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pkg-types": {
|
"node_modules/pkg-types": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
|
||||||
@@ -14752,6 +14800,12 @@
|
|||||||
"workbox-core": "7.3.0"
|
"workbox-core": "7.3.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==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/workbox-broadcast-update": {
|
"node_modules/workbox-broadcast-update": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz",
|
||||||
@@ -15090,6 +15144,12 @@
|
|||||||
"workbox-core": "7.3.0"
|
"workbox-core": "7.3.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==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/workbox-google-analytics": {
|
"node_modules/workbox-google-analytics": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz",
|
||||||
|
|||||||
@@ -15,9 +15,12 @@
|
|||||||
"nuxt": "^3.8.0"
|
"nuxt": "^3.8.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@vite-pwa/nuxt": "^1.0.4",
|
"@vite-pwa/nuxt": "^1.0.4",
|
||||||
"@vueuse/core": "^10.5.0",
|
"@vueuse/core": "^10.5.0",
|
||||||
"@vueuse/nuxt": "^10.5.0",
|
"@vueuse/nuxt": "^10.5.0",
|
||||||
"lucide-vue-next": "^0.536.0"
|
"idb": "^8.0.3",
|
||||||
|
"lucide-vue-next": "^0.536.0",
|
||||||
|
"pinia": "^3.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ definePageMeta({
|
|||||||
|
|
||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
|
import { useMusicStore } from '~/stores/music'
|
||||||
|
|
||||||
// Import components
|
// Import components
|
||||||
import AuroraBackground from '~/components/AuroraBackground.client.vue'
|
import AuroraBackground from '~/components/AuroraBackground.client.vue'
|
||||||
@@ -67,8 +68,11 @@ import MainContainer from '~/components/MainContainer.client.vue'
|
|||||||
import TrackList from '~/components/TrackList.client.vue'
|
import TrackList from '~/components/TrackList.client.vue'
|
||||||
import MusicControls from '~/components/MusicControls.client.vue'
|
import MusicControls from '~/components/MusicControls.client.vue'
|
||||||
|
|
||||||
|
// Store
|
||||||
|
const musicStore = useMusicStore()
|
||||||
|
|
||||||
// Reactive state
|
// Reactive state
|
||||||
const tracks = ref([])
|
const tracks = computed(() => musicStore.tracks)
|
||||||
const currentTrack = ref(null)
|
const currentTrack = ref(null)
|
||||||
const currentTrackIndex = ref(0)
|
const currentTrackIndex = ref(0)
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
@@ -102,16 +106,23 @@ const displayTracks = computed(() => {
|
|||||||
// Methods
|
// Methods
|
||||||
const loadTracks = async () => {
|
const loadTracks = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await $fetch('/api/music')
|
await musicStore.fetchTracks()
|
||||||
tracks.value = response.tracks
|
|
||||||
if (tracks.value.length > 0) {
|
if (tracks.value.length > 0) {
|
||||||
generateShuffledIndices()
|
generateShuffledIndices()
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load tracks:', error)
|
console.warn('Failed to load tracks from server, will fallback to cache:', error)
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
|
// Fallback to cached tracks when offline or server fails
|
||||||
|
const cached = await musicStore.loadCachedTracks()
|
||||||
|
if (cached.length > 0) {
|
||||||
|
// Use cached list locally by overwriting store tracks for session display
|
||||||
|
musicStore.tracks = cached
|
||||||
|
generateShuffledIndices()
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateShuffledIndices = () => {
|
const generateShuffledIndices = () => {
|
||||||
@@ -138,23 +149,36 @@ const playTrack = async (track, index) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Try IndexedDB cached version first
|
||||||
|
const cachedBlob = await musicStore.getCachedBlob(track.name)
|
||||||
|
if (cachedBlob) {
|
||||||
|
const audioUrl = URL.createObjectURL(cachedBlob)
|
||||||
|
audioPlayer.value.currentBlobUrl = audioUrl
|
||||||
|
audioPlayer.value.src = audioUrl
|
||||||
|
audioPlayer.value.load()
|
||||||
|
audioPlayer.value.addEventListener('canplay', () => {
|
||||||
|
loadingTrack.value = null
|
||||||
|
audioPlayer.value.play()
|
||||||
|
}, { once: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch and preload entire song into memory
|
// Fetch and preload entire song into memory
|
||||||
const encodedName = encodeURIComponent(track.name)
|
const encodedName = encodeURIComponent(track.name)
|
||||||
const response = await fetch(`/api/music/${encodedName}`)
|
const response = await fetch(`/api/music/${encodedName}`)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const audioUrl = URL.createObjectURL(blob)
|
const audioUrl = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
// No auto-cache: solo reproducir, el usuario decide cachear desde menú
|
||||||
// Store reference to revoke later
|
// Store reference to revoke later
|
||||||
audioPlayer.value.currentBlobUrl = audioUrl
|
audioPlayer.value.currentBlobUrl = audioUrl
|
||||||
audioPlayer.value.src = audioUrl
|
audioPlayer.value.src = audioUrl
|
||||||
console.log('Preloaded track into memory:', track.name)
|
console.log('Preloaded track into memory:', track.name)
|
||||||
audioPlayer.value.load()
|
audioPlayer.value.load()
|
||||||
|
|
||||||
// Auto-play when ready
|
// Auto-play when ready
|
||||||
audioPlayer.value.addEventListener('canplay', () => {
|
audioPlayer.value.addEventListener('canplay', () => {
|
||||||
loadingTrack.value = null
|
loadingTrack.value = null
|
||||||
@@ -359,6 +383,8 @@ const handleViewportChange = () => {
|
|||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadTracks()
|
loadTracks()
|
||||||
|
// Load cached songs metadata
|
||||||
|
musicStore.loadCachedNames()
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
|
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
|
||||||
|
|
||||||
@@ -391,4 +417,3 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
/* Page-specific styles */
|
/* Page-specific styles */
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
// Determine the music directory path
|
// Determine the music directory path
|
||||||
let musicDir: string
|
let musicDir: string
|
||||||
if (process.env.MUSIC_DIR) {
|
if (config.musicDirAbs || process.env.MUSIC_DIR) {
|
||||||
// If MUSIC_DIR is set, resolve it (handles both absolute and relative paths)
|
// Prefer absolute dir from runtimeConfig; fallback to env (resolved relative to repo root at build time)
|
||||||
musicDir = resolve(process.cwd(), process.env.MUSIC_DIR)
|
musicDir = (config.musicDirAbs as string) || resolve(process.cwd(), process.env.MUSIC_DIR!)
|
||||||
} else {
|
} else {
|
||||||
// Fallback to public/music
|
// Fallback to public/music
|
||||||
const defaultPublicPath = config.public?.musicPath || '/music'
|
const defaultPublicPath = config.public?.musicPath || '/music'
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
console.log(`[MUSIC API] Music list request from ${realIP}`)
|
console.log(`[MUSIC API] Music list request from ${realIP}`)
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
// Prefer absolute dir computed at build time; fallback to env or public path
|
||||||
const defaultPublicPath = config.public?.musicPath || '/music'
|
const defaultPublicPath = config.public?.musicPath || '/music'
|
||||||
const publicRel = defaultPublicPath.replace(/^\//, '')
|
const publicRel = defaultPublicPath.replace(/^\//, '')
|
||||||
const musicDir = process.env.MUSIC_DIR || join(process.cwd(), 'public', publicRel)
|
const musicDir = config.musicDirAbs || process.env.MUSIC_DIR || join(process.cwd(), 'public', publicRel)
|
||||||
|
|
||||||
// Check if music directory exists
|
// Check if music directory exists
|
||||||
try {
|
try {
|
||||||
|
|||||||
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