diff --git a/Dockerfile b/Dockerfile index 1f34c157..311638e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,7 +50,7 @@ COPY templates templates ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud" RUN DATABASE_URL="${DATABASE_URL}" cargo build --release # The SPA is built by the frontend stage; bring it in for the runtime copy below. -# (build.rs no longer generates static-dist unless OXICLOUD_LEGACY_ASSETS=1.) +# (build.rs no longer generates static-dist unless OXICLOUD_RUST_ASSETS=1.) COPY --from=frontend /static-dist ./static-dist # ─── Stage 4: Minimal runtime image ────────────────────────────────────────── diff --git a/build.rs b/build.rs index 1ae891bb..64333458 100644 --- a/build.rs +++ b/build.rs @@ -40,15 +40,15 @@ fn main() { println!("cargo:rerun-if-changed=static"); println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-env-changed=OXICLOUD_LEGACY_ASSETS"); + println!("cargo:rerun-if-env-changed=OXICLOUD_RUST_ASSETS"); git_status(); - // Post-cutover (Svelte/Vite): the frontend is built by Vite into - // `static-dist/` and the Rust web layer serves it directly — no `include_str!` - // HTML, no Rust-side bundling. The legacy pure-Rust asset pipeline below is - // retained, behind `OXICLOUD_LEGACY_ASSETS=1`, for one-release rollback only. - if env_or("OXICLOUD_LEGACY_ASSETS", "0") != "1" { + // The frontend is built by Vite into `static-dist/` and the Rust web layer + // serves it directly — no `include_str!` HTML, no Rust-side bundling. The + // pure-Rust asset pipeline below is retained, behind `OXICLOUD_RUST_ASSETS=1`, + // for one-release rollback only. + if env_or("OXICLOUD_RUST_ASSETS", "0") != "1" { return; } diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 1ddced4a..45415cfe 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -7,7 +7,9 @@ package-lock.json src/lib/i18n/locales/ # vendored / generated — kept byte-faithful to their source src/lib/styles/base/ -src/lib/styles/legacy/ -src/lib/styles/legacy.css +src/lib/styles/ported/ +src/lib/styles/ported.css src/lib/icons/registry.ts static/locales/ +static/vendors/ +static/workers/ diff --git a/frontend/.stylelintignore b/frontend/.stylelintignore index 7020a179..8c2dc071 100644 --- a/frontend/.stylelintignore +++ b/frontend/.stylelintignore @@ -4,5 +4,5 @@ node_modules/ # Ported verbatim from static/css/base — treated as vendored design tokens. # New component styles (Svelte diff --git a/frontend/src/lib/components/CommandPalette.svelte b/frontend/src/lib/components/CommandPalette.svelte new file mode 100644 index 00000000..43958019 --- /dev/null +++ b/frontend/src/lib/components/CommandPalette.svelte @@ -0,0 +1,384 @@ + + + + +{#if open} + +{/if} + + diff --git a/frontend/src/lib/components/DialogHost.svelte b/frontend/src/lib/components/DialogHost.svelte new file mode 100644 index 00000000..c92a9a9a --- /dev/null +++ b/frontend/src/lib/components/DialogHost.svelte @@ -0,0 +1,118 @@ + + +{#if dialogs.current} + {@const c = dialogs.current} + dialogs.cancel()}> + {#if c.kind === 'prompt'} +
+ {#if c.opts.message}

{c.opts.message}

{/if} + +
+ {:else if c.opts.message} +

{c.opts.message}

+ {/if} + + {#if dialogs.error} + + {/if} + + {#snippet footer()} + + {#if c.kind === 'prompt'} + + {:else} + + {/if} + {/snippet} +
+{/if} + + diff --git a/frontend/src/lib/components/FileRow.svelte b/frontend/src/lib/components/FileRow.svelte deleted file mode 100644 index 1a153aac..00000000 --- a/frontend/src/lib/components/FileRow.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - -
  • - - - {name} - {#if subtitle}{subtitle}{/if} - - {#if date}{date}{/if} - {#if actions}{@render actions()}{/if} -
  • - - diff --git a/frontend/src/lib/components/FileViewer.svelte b/frontend/src/lib/components/FileViewer.svelte new file mode 100644 index 00000000..1821e41b --- /dev/null +++ b/frontend/src/lib/components/FileViewer.svelte @@ -0,0 +1,401 @@ + + + + +{#if open && file} + + + + { + onrefresh?.(); + // If the editor was auto-opened for an Office doc, closing it should + // dismiss the whole viewer (there's nothing to preview behind it). + if (kind === 'other') close(); + }} + /> +{/if} + + diff --git a/frontend/src/lib/components/ListToolbar.svelte b/frontend/src/lib/components/ListToolbar.svelte new file mode 100644 index 00000000..0a1d9254 --- /dev/null +++ b/frontend/src/lib/components/ListToolbar.svelte @@ -0,0 +1,125 @@ + + + + +
    + {#if start}{@render start()}{:else}
    {/if} + + {#if groups?.length || showViewToggle} +
    + {#if groups?.length} +
    + + + {#if menuOpen} +
    + {#each groups as g (g.key)} + + {/each} +
    + {/if} +
    + {#if showViewToggle}{/if} + {/if} + {#if showViewToggle} + + + {/if} +
    + {/if} +
    diff --git a/frontend/src/lib/components/Modal.svelte b/frontend/src/lib/components/Modal.svelte index 0402b4cf..eb1bb642 100644 --- a/frontend/src/lib/components/Modal.svelte +++ b/frontend/src/lib/components/Modal.svelte @@ -12,14 +12,60 @@ let { open = $bindable(false), title, onclose, children, footer }: Props = $props(); + let dialogEl = $state(null); + let prevFocus: HTMLElement | null = null; + function close() { open = false; onclose?.(); } - function onkeydown(e: KeyboardEvent) { - if (e.key === 'Escape') close(); + const FOCUSABLE = + 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'; + + function focusables(): HTMLElement[] { + if (!dialogEl) return []; + return Array.from(dialogEl.querySelectorAll(FOCUSABLE)).filter( + (el) => el.offsetParent !== null || el === document.activeElement + ); } + + function onkeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + close(); + return; + } + // Focus trap: keep Tab cycling inside the dialog. + if (e.key === 'Tab') { + const items = focusables(); + if (items.length === 0) return; + const first = items[0]; + const last = items[items.length - 1]; + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + } + + // On open: remember the previously focused element and move focus into the + // dialog. On close: restore focus so keyboard users aren't dumped at . + $effect(() => { + if (open) { + prevFocus = (document.activeElement as HTMLElement | null) ?? null; + requestAnimationFrame(() => { + const items = focusables(); + (items[0] ?? dialogEl)?.focus(); + }); + } else if (prevFocus) { + prevFocus.focus(); + prevFocus = null; + } + }); @@ -33,7 +79,14 @@ if (e.target === e.currentTarget) close(); }} > - + + diff --git a/frontend/src/routes/music/+page.svelte b/frontend/src/routes/music/+page.svelte index d2d3eb7c..c8536cf4 100644 --- a/frontend/src/routes/music/+page.svelte +++ b/frontend/src/routes/music/+page.svelte @@ -2,16 +2,27 @@ import { onMount } from 'svelte'; import { fileInlineUrl } from '$lib/api/endpoints/files'; import { + addTracks, createPlaylist, deletePlaylist, listPlaylists, + listShares, listTracks, + removeShare, removeTrack, + renamePlaylist, reorderTracks, + sharePlaylist, + updatePlaylist, + uploadCoverImage, + type MusicShare, type Playlist, type PlaylistItem } from '$lib/api/endpoints/music'; + import { searchFiles } from '$lib/api/endpoints/search'; + import type { FileItem } from '$lib/api/types'; import Icon from '$lib/icons/Icon.svelte'; + import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; import { t } from '$lib/i18n/index.svelte'; import { ui } from '$lib/stores/ui.svelte'; @@ -20,11 +31,35 @@ let tracks = $state([]); let loading = $state(false); let error = $state(null); - let nowPlaying = $state(null); - // native HTML5 drag-reorder state let dragIndex = $state(null); + // ── Player (independent global queue) ────────────────────────────────────── + let audio = $state(null); + /** Playback queue — independent of the visible `tracks` list. */ + let queue = $state([]); + let currentIndex = $state(-1); + let playing = $state(false); + let currentTime = $state(0); + let duration = $state(0); + let volume = $state(0.7); + let muted = $state(false); + let shuffle = $state(false); + let repeat = $state<'none' | 'all' | 'one'>('none'); + let queueOpen = $state(false); + + const currentTrack = $derived(currentIndex >= 0 ? (queue[currentIndex] ?? null) : null); + + function fmtTime(s: number | null | undefined): string { + if (s == null || !Number.isFinite(s)) return '0:00'; + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${sec.toString().padStart(2, '0')}`; + } + function fmtDuration(s: number | null | undefined): string { + return s ? fmtTime(s) : '-'; + } + async function loadPlaylists() { loading = true; error = null; @@ -40,6 +75,7 @@ async function select(p: Playlist) { current = p; + // NOTE: do NOT touch the player here — browsing a playlist must not stop playback. try { tracks = await listTracks(p.id); } catch (e) { @@ -48,20 +84,65 @@ } async function onCreate() { - const name = prompt(t('music.new_playlist', 'New playlist name')); + const name = await promptDialog({ + title: t('music.new_playlist', 'New playlist'), + confirmText: t('common.create', 'Create') + }); if (!name) return; try { const p = await createPlaylist(name); - playlists = [...playlists, p]; + playlists = [p, ...playlists]; await select(p); + ui.notify(t('music.created', { name: p.name }, 'Created “{{name}}”.'), 'success'); + } catch (e) { + ui.notify(e instanceof Error ? e.message : String(e), 'error'); + } + } + + async function onRenamePlaylist() { + if (!current) return; + const name = await promptDialog({ + title: t('music.rename_playlist', 'Rename playlist'), + defaultValue: current.name, + confirmText: t('common.save', 'Save') + }); + if (!name || name === current.name) return; + try { + await renamePlaylist(current.id, name); + current.name = name; + playlists = playlists.map((p) => (p.id === current!.id ? { ...p, name } : p)); + } catch (e) { + ui.notify(e instanceof Error ? e.message : String(e), 'error'); + } + } + + async function onEditDescription() { + if (!current) return; + const desc = await promptDialog({ + title: t('music.edit_description', 'Edit description'), + defaultValue: current.description ?? '', + confirmText: t('common.save', 'Save') + }); + if (desc === null) return; + try { + await updatePlaylist(current.id, { description: desc || null }); + current.description = desc || null; + playlists = playlists.map((p) => + p.id === current!.id ? { ...p, description: desc || null } : p + ); } catch (e) { ui.notify(e instanceof Error ? e.message : String(e), 'error'); } } async function onDelete(p: Playlist) { - if (!confirm(t('music.confirm_delete', { name: p.name }, 'Delete playlist "{{name}}"?'))) - return; + const ok = await confirmDialog({ + title: t('music.delete_playlist', 'Delete playlist'), + message: t('music.confirm_delete', { name: p.name }, 'Delete playlist "{{name}}"?'), + confirmText: t('common.delete', 'Delete'), + danger: true + }); + if (!ok) return; try { await deletePlaylist(p.id); playlists = playlists.filter((x) => x.id !== p.id); @@ -69,6 +150,7 @@ current = playlists[0] ?? null; tracks = current ? await listTracks(current.id) : []; } + ui.notify(t('music.deleted', { name: p.name }, 'Deleted “{{name}}”.'), 'success'); } catch (e) { ui.notify(e instanceof Error ? e.message : String(e), 'error'); } @@ -79,6 +161,25 @@ try { await removeTrack(current.id, track.file_id); tracks = tracks.filter((x) => x.id !== track.id); + ui.notify(t('music.track_removed', 'Track removed.'), 'success'); + } catch (e) { + ui.notify(e instanceof Error ? e.message : String(e), 'error'); + } + } + + async function onTogglePublic() { + if (!current) return; + const next = !current.is_public; + try { + await updatePlaylist(current.id, { is_public: next }); + current.is_public = next; + playlists = playlists.map((p) => (p.id === current!.id ? { ...p, is_public: next } : p)); + ui.notify( + next + ? t('music.now_public', 'Playlist is now public.') + : t('music.now_private', 'Playlist is now private.'), + 'success' + ); } catch (e) { ui.notify(e instanceof Error ? e.message : String(e), 'error'); } @@ -87,7 +188,6 @@ function onDragStart(i: number) { dragIndex = i; } - function onDragOver(e: DragEvent, i: number) { e.preventDefault(); if (dragIndex === null || dragIndex === i) return; @@ -97,7 +197,6 @@ dragIndex = i; tracks = next; } - async function onDrop() { dragIndex = null; if (!current) return; @@ -106,9 +205,10 @@ current.id, tracks.map((tr) => tr.id) ); + ui.notify(t('music.reordered', 'Playlist reordered.'), 'success'); } catch (e) { ui.notify(e instanceof Error ? e.message : String(e), 'error'); - await select(current); // reload server order on failure + await select(current); } } @@ -116,245 +216,859 @@ return tr.title || tr.file_name || tr.file_id; } + // ── Transport (operates on the independent queue) ────────────────────────── + /** Replace the queue (e.g. when starting playback of the visible playlist). */ + function setQueue(list: PlaylistItem[]) { + queue = [...list]; + } + + function playIndex(i: number) { + if (i < 0 || i >= queue.length) return; + currentIndex = i; + // $effect swaps the src; ensure playback starts. + queueMicrotask(() => audio?.play().catch(() => {})); + } + + /** Play the visible playlist from a given row, seeding the queue from it. */ + function playFromTracks(i: number) { + if (i < 0 || i >= tracks.length) return; + setQueue(tracks); + playIndex(i); + } + + function playAll() { + if (tracks.length) playFromTracks(0); + } + + function shufflePlay() { + if (!tracks.length) return; + const shuffled = [...tracks]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + setQueue(shuffled); + playIndex(0); + } + + function togglePlay() { + if (!audio) return; + if (currentIndex < 0 && queue.length) { + playIndex(0); + return; + } + if (playing) audio.pause(); + else audio.play().catch(() => {}); + } + function next() { + if (!queue.length) return; + if (shuffle) { + playIndex(Math.floor(Math.random() * queue.length)); + return; + } + if (currentIndex + 1 < queue.length) playIndex(currentIndex + 1); + else if (repeat === 'all') playIndex(0); + } + function prev() { + if (!queue.length) return; + if (currentTime > 3 && audio) { + audio.currentTime = 0; + return; + } + // Wrap to the last track when at the start (OLD behavior). + playIndex(currentIndex > 0 ? currentIndex - 1 : queue.length - 1); + } + function onEnded() { + if (repeat === 'one') { + if (audio) audio.currentTime = 0; + audio?.play().catch(() => {}); + return; + } + next(); + } + function seek(e: Event) { + const v = Number((e.target as HTMLInputElement).value); + if (audio) audio.currentTime = v; + } + function applyVolume() { + if (audio) { + audio.volume = volume; + audio.muted = muted; + } + } + function setVolume(e: Event) { + volume = Number((e.target as HTMLInputElement).value); + muted = volume === 0; + applyVolume(); + } + function toggleMute() { + muted = !muted; + applyVolume(); + } + function cycleRepeat() { + repeat = repeat === 'none' ? 'all' : repeat === 'all' ? 'one' : 'none'; + } + + const volumeIcon = $derived(muted || volume === 0 ? 'volume' : 'volume-up'); + + function jumpQueue(i: number) { + playIndex(i); + } + function removeFromQueue(i: number) { + const next = [...queue]; + next.splice(i, 1); + if (i === currentIndex) { + queue = next; + if (next.length === 0) { + audio?.pause(); + currentIndex = -1; + } else { + playIndex(i >= next.length ? 0 : i); + } + } else { + if (i < currentIndex) currentIndex -= 1; + queue = next; + } + } + + /** Live duration backfill: write the real duration into rows/queue lacking it. */ + function onLoadedMetadata() { + duration = audio?.duration ?? 0; + const tr = currentTrack; + if (tr && audio?.duration && !tr.duration_secs) { + const secs = Math.round(audio.duration); + tr.duration_secs = secs; + queue = queue.map((q) => (q.id === tr.id ? { ...q, duration_secs: secs } : q)); + tracks = tracks.map((q) => (q.id === tr.id ? { ...q, duration_secs: secs } : q)); + } + } + + function onAudioError() { + if (!currentTrack) return; + ui.notify( + t('music.playback_error', { name: trackLabel(currentTrack) }, 'Playback error: {{name}}'), + 'error' + ); + playing = false; + } + + // Keep the