refactor: isolate application state per Discord server

This commit is contained in:
mmacierzanka committed 2026-08-21 16:14:22 +02:00
1 parent 94be2c7c79
commit 64b428555c
30 files changed
+330 -471

No files matched your search

-1
View File
@@ -62,7 +62,6 @@ src/
├── QueueItem.vue pozycja kolejki wraz z limitem odtworzeń
├── HistoryPanel.vue historia z ponownym dodaniem do kolejki
├── ModalShell.vue wspólna ramka okien modalnych
├── SettingsModal.vue ustawienia bota i kanałów logów
├── UploadsLibraryModal.vue pełna lista plików na serwerze
└── ToastHost.vue powiadomienia
```
+4 -13
View File
@@ -109,6 +109,7 @@ const byTitle = (title) => app.querySelector(`[title="${title}"]`)
const results = []
const check = (name, ok) => results.push([name, ok])
const hasCall = (expected) => calls.some((call) => call === expected || call.startsWith(`${expected}?`))
check('aplikacja zamontowana', app.children.length > 0)
check('nagłówek', text().includes('ŻyrarBOT'))
@@ -127,7 +128,7 @@ check('ikony renderowane jako SVG', app.querySelectorAll('svg').length > 10)
check('pozycje list', app.querySelectorAll('.queue-item').length === 4)
check('podświetlona aktywna pozycja', app.querySelectorAll('.playing-item').length === 1)
check('animacja winylu przy odtwarzaniu', app.querySelector('.vinyl-record.playing') !== null)
check('pobrano listę plików na starcie', calls.includes('GET /api/uploads'))
check('pobrano listę plików na starcie', hasCall('GET /api/uploads'))
// --- Interakcje: kliknięcie ma trafić w konkretny endpoint ---------------
@@ -135,7 +136,8 @@ async function clickExpects(title, expected) {
const before = calls.length
byTitle(title)?.click()
await settle()
check(`${title} -> ${expected}`, calls.slice(before).includes(expected))
check(`${title} -> ${expected}`, calls.slice(before).some((call) =>
call === expected || call.startsWith(`${expected}?`)))
}
await clickExpects('Wstrzymaj', 'POST /api/pause')
@@ -150,17 +152,6 @@ await clickExpects('Dodaj do kolejki', 'POST /api/play_file')
await clickExpects('Dodaj ponownie do kolejki', 'POST /api/play')
await clickExpects('Usuń z serwera', 'DELETE /api/uploads/sample.mp3')
// Modal ustawień otwiera się i zapisuje domyślną liczbę odtworzeń.
;[...app.querySelectorAll('button')].find((b) => b.textContent.includes('Ustawienia'))?.click()
await settle()
check('modal ustawień pokazuje serwery', doc.body.textContent.includes('ID: 1'))
const before = calls.length
;[...doc.querySelectorAll('button')].find((b) => b.textContent.includes('Zapisz'))?.click()
await settle()
check('zapis ustawień -> POST /api/settings/default_plays',
calls.slice(before).includes('POST /api/settings/default_plays'))
const failed = results.filter(([, ok]) => !ok)
for (const [name, ok] of results) console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`)
console.log(failed.length ? `\n${failed.length} z ${results.length} niepowodzeń` : `\nOK - ${results.length} sprawdzeń`)
+23 -10
View File
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import AddTrackPanel from '@/components/AddTrackPanel.vue'
import AppHeader from '@/components/AppHeader.vue'
import HistoryPanel from '@/components/HistoryPanel.vue'
import PlayerPanel from '@/components/PlayerPanel.vue'
import QueuePanel from '@/components/QueuePanel.vue'
import SettingsModal from '@/components/SettingsModal.vue'
import ToastHost from '@/components/ToastHost.vue'
import UploadsLibraryModal from '@/components/UploadsLibraryModal.vue'
import UploadsPanel from '@/components/UploadsPanel.vue'
@@ -13,11 +12,28 @@ import VoiceChannelPanel from '@/components/VoiceChannelPanel.vue'
import { useBotStatus } from '@/composables/useBotStatus'
import { useUploads } from '@/composables/useUploads'
const { status, connect } = useBotStatus()
const { status, connect, selectGuild } = useBotStatus()
const { refresh } = useUploads()
const settingsOpen = ref(false)
const libraryOpen = ref(false)
const selectedGuildId = ref<string | null>(null)
watch(
() => status.value?.guilds,
(guilds) => {
if (guilds?.length && !selectedGuildId.value) {
selectedGuildId.value = guilds[0].guild_id
selectGuild(guilds[0].guild_id)
void refresh()
}
},
)
async function changeGuild(guildId: string) {
selectedGuildId.value = guildId
selectGuild(guildId)
await refresh()
}
onMounted(() => {
connect()
@@ -32,7 +48,9 @@ onMounted(() => {
<div class="app-container">
<AppHeader
:status="status"
@open-settings="settingsOpen = true"
:guilds="status?.guilds ?? []"
:selected-guild-id="selectedGuildId"
@select-guild="changeGuild"
/>
<main class="dashboard-grid">
@@ -53,11 +71,6 @@ onMounted(() => {
</main>
</div>
<SettingsModal
v-if="settingsOpen"
:status="status"
@close="settingsOpen = false"
/>
<UploadsLibraryModal
v-if="libraryOpen"
:status="status"
+16 -5
View File
@@ -2,10 +2,25 @@
import type { BotStatus, UploadedFile } from '@/types/status'
let selectedGuildId: string | null = null
export function getGuildId() {
return selectedGuildId
}
export function setGuildId(guildId: string) {
selectedGuildId = guildId
}
function withGuild(path: string) {
if (!selectedGuildId) return path
return `${path}${path.includes('?') ? '&' : '?'}guild_id=${encodeURIComponent(selectedGuildId ?? '')}`
}
export class ApiError extends Error {}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, init)
const response = await fetch(withGuild(path), init)
const payload = await response.json().catch(() => null)
if (!response.ok) {
@@ -52,8 +67,4 @@ export const api = {
return request<{ filename: string }>('/api/upload', { method: 'POST', body: form })
},
setDefaultPlays: (defaultPlays: number) =>
post('/api/settings/default_plays', { default_plays: defaultPlays }),
setLogChannel: (guildId: string, channelId: string) =>
post('/api/settings/text_channel', { guild_id: guildId, channel_id: channelId }),
}
+2 -68
View File
@@ -988,7 +988,7 @@ body {
background: rgba(255, 255, 255, 0.02);
}
#btn-close-settings, #btn-close-uploads {
#btn-close-uploads {
width: 42px;
height: 42px;
background: rgba(255, 255, 255, 0.12);
@@ -1002,7 +1002,7 @@ body {
cursor: pointer;
}
#btn-close-settings:hover, #btn-close-uploads:hover {
#btn-close-uploads:hover {
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.6);
color: #ff6b6b;
@@ -1016,20 +1016,6 @@ body {
flex: 1;
}
.settings-section {
margin-bottom: 24px;
}
.settings-section h3 {
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-main);
margin-bottom: 8px;
}
.settings-desc {
font-size: 0.9rem;
color: var(--text-muted);
@@ -1037,58 +1023,6 @@ body {
line-height: 1.5;
}
.guild-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-glass);
margin-bottom: 12px;
gap: 16px;
}
.guild-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.guild-icon {
width: 42px;
height: 42px;
border-radius: 12px;
background: rgba(139, 92, 246, 0.15);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
}
.guild-name {
font-weight: 600;
color: var(--text-main);
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.guild-id {
font-size: 0.75rem;
color: var(--text-muted);
}
.guild-control {
width: 240px;
flex-shrink: 0;
}
/* Zapobieganie rozjeżdżaniu się layoutu przy długich tytułach nagrań */
.now-playing-info,
.track-title,
+21 -9
View File
@@ -1,10 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Music, Settings } from 'lucide-vue-next'
import type { BotStatus } from '@/types/status'
import { Music } from 'lucide-vue-next'
import type { BotStatus, GuildSettings } from '@/types/status'
const props = defineProps<{ status: BotStatus | null }>()
defineEmits<{ openSettings: [] }>()
const props = defineProps<{
status: BotStatus | null
guilds: GuildSettings[]
selectedGuildId: string | null
}>()
defineEmits<{ selectGuild: [guildId: string] }>()
const activeChannel = computed(() => props.status?.active_voice_channel ?? null)
@@ -28,12 +32,20 @@ const label = computed(() =>
</div>
<div class="header-actions">
<button
class="btn btn-secondary"
@click="$emit('openSettings')"
<select
v-if="guilds.length"
:value="selectedGuildId ?? undefined"
class="premium-select"
@change="$emit('selectGuild', ($event.target as HTMLSelectElement).value)"
>
<Settings /> Ustawienia
</button>
<option
v-for="guild in guilds"
:key="guild.guild_id"
:value="guild.guild_id"
>
{{ guild.guild_name }}
</option>
</select>
<div class="bot-status-badge">
<span
class="status-indicator"
-173
View File
@@ -1,173 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Repeat, Save, Server, Settings, Shield } from 'lucide-vue-next'
import ModalShell from '@/components/ModalShell.vue'
import { api } from '@/api/client'
import { useToasts } from '@/composables/useToasts'
import type { BotStatus } from '@/types/status'
const MIN_PLAYS = 1
const MAX_PLAYS = 999
const props = defineProps<{ status: BotStatus | null }>()
defineEmits<{ close: [] }>()
const { notify, run } = useToasts()
const defaultPlays = ref(props.status?.default_plays ?? 5)
const editing = ref(false)
const guilds = computed(() => props.status?.guilds ?? [])
watch(
() => props.status?.default_plays,
(value) => {
if (!editing.value && value !== undefined) defaultPlays.value = value
},
{ immediate: true },
)
async function saveDefaultPlays() {
const value = Number(defaultPlays.value)
if (!Number.isInteger(value) || value < MIN_PLAYS || value > MAX_PLAYS) {
notify('Wartość musi być z przedziału 1-999', 'error')
return
}
await run(
() => api.setDefaultPlays(value),
`Domyślna liczba odtworzeń: ${value}`,
'Błąd zapisu ustawienia',
)
}
const saveLogChannel = (guildId: string, channelId: string) =>
run(
() => api.setLogChannel(guildId, channelId),
'Zapisano kanał powiadomień dla serwera!',
'Nie udało się zapisać kanału powiadomień',
)
</script>
<template>
<ModalShell @close="$emit('close')">
<template #title>
<Settings /> Ustawienia ŻyrarBOT
</template>
<section class="settings-section divided">
<h3><Repeat /> Domyślna liczba odtworzeń w kolejce</h3>
<p class="settings-desc">
Ustaw domyślną liczbę odtworzeń dla każdego nowo dodawanego utworu do kolejki. Przy każdym
odtworzeniu utworu wartość jest pomniejszana o 1, a po spadku do 0 utwór usuwany jest
z kolejki.
</p>
<div class="plays-row">
<input
v-model.number="defaultPlays"
type="number"
class="plays-input"
:min="MIN_PLAYS"
:max="MAX_PLAYS"
@focus="editing = true"
@blur="editing = false"
>
<button
class="btn btn-primary"
@click="saveDefaultPlays"
>
<Save /> Zapisz
</button>
</div>
</section>
<section class="settings-section">
<h3><Server /> Konfiguracja Serwerów Discord (Kanały Logów)</h3>
<p class="settings-desc">
Wybierz kanał tekstowy na każdym serwerze Discord, na który bot ma wysyłać powiadomienia
o odtwarzanych utworach i zmianach w kolejce.
</p>
<div class="guilds-list">
<div
v-for="guild in guilds"
:key="guild.guild_id"
class="guild-setting-row glass-card-subtle"
>
<div class="guild-info">
<div class="guild-icon">
<Shield />
</div>
<div>
<div class="guild-name">
{{ guild.guild_name }}
</div>
<div class="guild-id">
ID: {{ guild.guild_id }}
</div>
</div>
</div>
<div class="guild-control">
<select
class="premium-select"
:value="guild.active_notification_channel_id ?? ''"
@change="saveLogChannel(guild.guild_id, ($event.target as HTMLSelectElement).value)"
>
<option value="">
-- Brak / Domyślny --
</option>
<option
v-for="channel in guild.text_channels"
:key="channel.channel_id"
:value="channel.channel_id"
>
#{{ channel.channel_name }}
</option>
</select>
</div>
</div>
<div
v-if="!guilds.length"
class="empty-queue-message"
>
<Server />
<p>Brak serwerów Discord w pamięci bota.</p>
</div>
</div>
</section>
</ModalShell>
</template>
<style scoped>
.settings-section.divided {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.plays-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.plays-input {
width: 100px;
text-align: center;
font-weight: bold;
font-size: 1.1rem;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 10px;
color: var(--text-main);
}
.plays-row .btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
}
</style>
+27 -6
View File
@@ -1,6 +1,7 @@
import { onUnmounted, ref } from 'vue'
import type { BotStatus } from '@/types/status'
import { useToasts } from '@/composables/useToasts'
import { getGuildId, setGuildId } from '@/api/client'
const RECONNECT_DELAY_MS = 3000
@@ -20,9 +21,13 @@ export function useBotStatus() {
function open() {
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
socket = new WebSocket(`${scheme}//${window.location.host}/ws`)
const guild = getGuildId()
const currentSocket = new WebSocket(
`${scheme}//${window.location.host}/ws${guild ? `?guild_id=${guild}` : ''}`,
)
socket = currentSocket
socket.onopen = () => {
currentSocket.onopen = () => {
connected.value = true
notify(
connectedOnce ? 'Połączenie WebSocket przywrócone (Live)!' : 'WebSocket zsynchronizowany (Live)!',
@@ -31,7 +36,8 @@ export function useBotStatus() {
connectedOnce = true
}
socket.onmessage = (event) => {
currentSocket.onmessage = (event) => {
if (socket !== currentSocket) return
if (paused.value) return
try {
status.value = JSON.parse(event.data) as BotStatus
@@ -40,7 +46,8 @@ export function useBotStatus() {
}
}
socket.onclose = () => {
currentSocket.onclose = () => {
if (socket !== currentSocket) return
connected.value = false
socket = null
if (subscribers > 0) {
@@ -48,7 +55,7 @@ export function useBotStatus() {
}
}
socket.onerror = () => socket?.close()
currentSocket.onerror = () => currentSocket.close()
}
function connect() {
@@ -67,7 +74,21 @@ export function useBotStatus() {
socket = null
}
function selectGuild(guildId: string) {
setGuildId(guildId)
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (socket) {
const previousSocket = socket
socket = null
previousSocket.close()
open()
}
}
onUnmounted(disconnect)
return { status, connected, paused, connect, disconnect }
return { status, connected, paused, connect, disconnect, selectGuild }
}