From 119bff00a3bd01c22dfd03b606d64c71a8d270b5 Mon Sep 17 00:00:00 2001 From: lgoose Date: Fri, 21 Aug 2026 01:08:04 +0200 Subject: [PATCH] feat(frontend): composables ze stanem panelu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logika, która w app.js żyła w zmiennych globalnych, trafia do czterech composables o wyraźnym zakresie odpowiedzialności: - useBotStatus jedno połączenie WebSocket na aplikację, z licznikiem subskrybentów i ponawianiem po zerwaniu. Flaga paused wstrzymuje aktualizacje na czas przeciągania utworów. - useToasts kolejka powiadomień wraz z helperem run(), który opakowuje wywołanie API w parę komunikatów sukces/błąd. - useTrackProgress płynny pasek postępu - między pakietami z serwera czas dopisujemy lokalnie, a każdy pakiet resynchronizuje licznik. - useUploads serwerowa baza plików, współdzielona przez kafelek na pulpicie i okno z pełną listą. Dochodzi też utils/plural.ts z polską odmianą liczebników, zamiast powtarzanego w kilku miejscach warunku count < 5. Co-Authored-By: Claude Opus 5 --- frontend/src/composables/useBotStatus.ts | 73 ++++++++++++++++++++ frontend/src/composables/useToasts.ts | 42 +++++++++++ frontend/src/composables/useTrackProgress.ts | 50 ++++++++++++++ frontend/src/composables/useUploads.ts | 48 +++++++++++++ frontend/src/utils/plural.ts | 14 ++++ 5 files changed, 227 insertions(+) create mode 100644 frontend/src/composables/useBotStatus.ts create mode 100644 frontend/src/composables/useToasts.ts create mode 100644 frontend/src/composables/useTrackProgress.ts create mode 100644 frontend/src/composables/useUploads.ts create mode 100644 frontend/src/utils/plural.ts diff --git a/frontend/src/composables/useBotStatus.ts b/frontend/src/composables/useBotStatus.ts new file mode 100644 index 0000000..caa79dd --- /dev/null +++ b/frontend/src/composables/useBotStatus.ts @@ -0,0 +1,73 @@ +import { onUnmounted, ref } from 'vue' +import type { BotStatus } from '@/types/status' +import { useToasts } from '@/composables/useToasts' + +const RECONNECT_DELAY_MS = 3000 + +const status = ref(null) +const connected = ref(false) +/** Wstrzymuje przyjmowanie aktualizacji, gdy użytkownik przeciąga utwory. */ +const paused = ref(false) + +let socket: WebSocket | null = null +let reconnectTimer: number | null = null +let connectedOnce = false +let subscribers = 0 + +/** Jedno połączenie WebSocket na aplikację, niezależnie od liczby komponentów. */ +export function useBotStatus() { + const { notify } = useToasts() + + function open() { + const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + socket = new WebSocket(`${scheme}//${window.location.host}/ws`) + + socket.onopen = () => { + connected.value = true + notify( + connectedOnce ? 'Połączenie WebSocket przywrócone (Live)!' : 'WebSocket zsynchronizowany (Live)!', + 'success', + ) + connectedOnce = true + } + + socket.onmessage = (event) => { + if (paused.value) return + try { + status.value = JSON.parse(event.data) as BotStatus + } catch (error) { + console.error('Błąd parsowania danych z WS:', error) + } + } + + socket.onclose = () => { + connected.value = false + socket = null + if (subscribers > 0) { + reconnectTimer = window.setTimeout(open, RECONNECT_DELAY_MS) + } + } + + socket.onerror = () => socket?.close() + } + + function connect() { + subscribers += 1 + if (!socket) open() + } + + function disconnect() { + subscribers = Math.max(0, subscribers - 1) + if (subscribers > 0) return + if (reconnectTimer !== null) { + window.clearTimeout(reconnectTimer) + reconnectTimer = null + } + socket?.close() + socket = null + } + + onUnmounted(disconnect) + + return { status, connected, paused, connect, disconnect } +} diff --git a/frontend/src/composables/useToasts.ts b/frontend/src/composables/useToasts.ts new file mode 100644 index 0000000..2b54115 --- /dev/null +++ b/frontend/src/composables/useToasts.ts @@ -0,0 +1,42 @@ +import { ref } from 'vue' + +export type ToastType = 'success' | 'error' | 'info' + +export interface Toast { + id: number + message: string + type: ToastType +} + +const TOAST_LIFETIME_MS = 3500 + +const toasts = ref([]) +let nextId = 0 + +/** Kolejka powiadomień współdzielona przez całą aplikację. */ +export function useToasts() { + function dismiss(id: number) { + toasts.value = toasts.value.filter((toast) => toast.id !== id) + } + + function notify(message: string, type: ToastType = 'success') { + const id = nextId++ + toasts.value = [...toasts.value, { id, message, type }] + window.setTimeout(() => dismiss(id), TOAST_LIFETIME_MS) + } + + /** Uruchamia akcję API i zamienia jej wynik na powiadomienie. */ + async function run(action: () => Promise, success: string, failure: string) { + try { + await action() + if (success) notify(success, 'success') + return true + } catch (error) { + const detail = error instanceof Error ? error.message : '' + notify(detail ? `${failure}: ${detail}` : failure, 'error') + return false + } + } + + return { toasts, notify, dismiss, run } +} diff --git a/frontend/src/composables/useTrackProgress.ts b/frontend/src/composables/useTrackProgress.ts new file mode 100644 index 0000000..8283576 --- /dev/null +++ b/frontend/src/composables/useTrackProgress.ts @@ -0,0 +1,50 @@ +import { computed, onUnmounted, ref, watch, type Ref } from 'vue' +import type { BotStatus } from '@/types/status' + +const TICK_MS = 100 + +/** + * Płynny pasek postępu. Backend przysyła `elapsed` raz na sekundę, więc między + * aktualizacjami dopisujemy czas lokalnie, a każdy pakiet resynchronizuje licznik. + */ +export function useTrackProgress(status: Ref) { + const elapsed = ref(0) + const duration = ref(0) + + const timer = window.setInterval(() => { + if (!status.value?.playing || !status.value.current) return + elapsed.value = duration.value > 0 + ? Math.min(elapsed.value + TICK_MS / 1000, duration.value) + : elapsed.value + TICK_MS / 1000 + }, TICK_MS) + + watch( + () => status.value?.current, + (current) => { + duration.value = current?.duration ?? 0 + elapsed.value = current?.elapsed ?? 0 + }, + { immediate: true, deep: true }, + ) + + onUnmounted(() => window.clearInterval(timer)) + + const percent = computed(() => + duration.value > 0 ? Math.min((elapsed.value / duration.value) * 100, 100) : 100, + ) + + return { elapsed, duration, percent } +} + +export function formatTime(seconds: number | null | undefined): string { + if (seconds === null || seconds === undefined || Number.isNaN(seconds) || seconds < 0) { + return '0:00' + } + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = Math.floor(seconds % 60).toString().padStart(2, '0') + + return hours > 0 + ? `${hours}:${minutes.toString().padStart(2, '0')}:${secs}` + : `${minutes}:${secs}` +} diff --git a/frontend/src/composables/useUploads.ts b/frontend/src/composables/useUploads.ts new file mode 100644 index 0000000..56116f4 --- /dev/null +++ b/frontend/src/composables/useUploads.ts @@ -0,0 +1,48 @@ +import { ref } from 'vue' +import { api } from '@/api/client' +import type { UploadedFile } from '@/types/status' +import { useToasts } from '@/composables/useToasts' + +/** Ile plików pokazuje kafelek na pulpicie - reszta czeka w oknie modalnym. */ +export const DASHBOARD_FILE_LIMIT = 10 + +const files = ref([]) +const uploading = ref(false) +const uploadLabel = ref('') + +/** Serwerowa baza plików współdzielona przez kafelek i okno z pełną listą. */ +export function useUploads() { + const { notify, run } = useToasts() + + async function refresh() { + try { + files.value = (await api.uploads()).files + } catch (error) { + console.error('Błąd pobierania plików:', error) + } + } + + async function upload(selected: FileList) { + uploading.value = true + for (const file of Array.from(selected)) { + uploadLabel.value = `Przesyłanie: ${file.name}...` + await run(() => api.upload(file), `Przesłano plik: ${file.name}`, `Błąd przesyłania ${file.name}`) + } + uploading.value = false + uploadLabel.value = '' + await refresh() + } + + async function remove(filename: string) { + if (await run(() => api.deleteUpload(filename), 'Usunięto plik z serwera', 'Nie udało się usunąć pliku')) { + await refresh() + } + } + + async function enqueue(filename: string) { + await run(() => api.playFile(filename), 'Dodano plik do kolejki!', 'Błąd odtwarzania') + await refresh() + } + + return { files, uploading, uploadLabel, refresh, upload, remove, enqueue, notify } +} diff --git a/frontend/src/utils/plural.ts b/frontend/src/utils/plural.ts new file mode 100644 index 0000000..7193425 --- /dev/null +++ b/frontend/src/utils/plural.ts @@ -0,0 +1,14 @@ +/** Polska odmiana rzeczownika po liczebniku: 1 utwór, 2 utwory, 5 utworów. */ +export function plural(count: number, one: string, few: string, many: string): string { + const lastDigit = count % 10 + const lastTwo = count % 100 + + if (count === 1) return `${count} ${one}` + if (lastDigit >= 2 && lastDigit <= 4 && !(lastTwo >= 12 && lastTwo <= 14)) { + return `${count} ${few}` + } + return `${count} ${many}` +} + +export const tracksLabel = (count: number) => plural(count, 'utwór', 'utwory', 'utworów') +export const filesLabel = (count: number) => plural(count, 'plik', 'pliki', 'plików')