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:
1 parent
6bfa9b2a77
commit
119bff00a3
5 files changed
+227
No files matched your search
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
Reference in new issue
Block a user