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