feat(frontend): composables ze stanem panelu

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 <noreply@anthropic.com>
This commit is contained in:
lgooseandClaude Bot committed 2026-08-21 01:08:04 +02:00
1 parent 6bfa9b2a77
commit 119bff00a3
5 files changed
+227

No files matched your search

+73
View File
@@ -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<BotStatus | null>(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 }
}
+42
View File
@@ -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<Toast[]>([])
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<unknown>, 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 }
}
@@ -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<BotStatus | null>) {
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}`
}
+48
View File
@@ -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<UploadedFile[]>([])
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 }
}
+14
View File
@@ -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')