feat(frontend): komponenty panelu

Widok rozbity na komponenty odpowiadające kafelkom pulpitu:

- AppHeader, VoiceChannelPanel, PlayerPanel, AddTrackPanel
- UploadsPanel wraz z UploadedFileRow współdzielonym z oknem modalnym
- QueuePanel i QueueItem - przeciąganie, zmiana kolejności, limit odtworzeń
- HistoryPanel, ModalShell, SettingsModal, UploadsLibraryModal, ToastHost

Arkusz stylów przeniesiony ze static/style.css bez zmian w wyglądzie.
Style, które app.js wstrzykiwał inline przy budowaniu HTML-a, przeniesione
do klas współdzielonych i bloków scoped w komponentach.

Poprawki bezpieczeństwa wynikające z przepisania:

- Tytuły utworów i nazwy plików nie trafiają już do innerHTML. Poprzednia
  wersja składała wiersze kolejki, historii i listy plików ze stringów,
  więc tytuł zawierający znaczniki HTML był wstawiany do DOM bez escapowania.
- Nazwy plików nie są już wklejane do atrybutów onclick z ręcznym
  escapowaniem apostrofu, które i tak nie obejmowało cudzysłowu. Zdarzenia
  podpina Vue, bez generowania kodu w stringach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
lgooseandClaude Bot committed 2026-08-21 01:08:31 +02:00
1 parent 119bff00a3
commit 038fa2ead4
16 files changed
+1245

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { onMounted, ref } 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'
import VoiceChannelPanel from '@/components/VoiceChannelPanel.vue'
import { useBotStatus } from '@/composables/useBotStatus'
import { useUploads } from '@/composables/useUploads'
const { status, connect } = useBotStatus()
const { refresh } = useUploads()
const settingsOpen = ref(false)
const libraryOpen = ref(false)
onMounted(() => {
connect()
refresh()
})
</script>
<template>
<div class="background-glow bg-glow-1"></div>
<div class="background-glow bg-glow-2"></div>
<div class="app-container">
<AppHeader :status="status" @open-settings="settingsOpen = true" />
<main class="dashboard-grid">
<section class="grid-column left-column">
<VoiceChannelPanel :status="status" />
<PlayerPanel :status="status" />
</section>
<section class="grid-column right-column">
<AddTrackPanel :status="status" />
<UploadsPanel :status="status" @open-library="libraryOpen = true" />
<QueuePanel :status="status" />
<HistoryPanel :status="status" />
</section>
</main>
</div>
<SettingsModal v-if="settingsOpen" :status="status" @close="settingsOpen = false" />
<UploadsLibraryModal v-if="libraryOpen" :status="status" @close="libraryOpen = false" />
<ToastHost />
</template>
@@ -1114,3 +1114,58 @@ body {
white-space: nowrap;
display: block;
}
/* ==========================================================================
Klasy współdzielone przez komponenty Vue.
Zastępują style, które wcześniej były wstrzykiwane inline z JavaScriptu.
========================================================================== */
.btn-block {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-compact {
padding: 4px 10px;
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 4px;
}
.queue-track-meta {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 2px;
}
/* Krótsze listy: baza plików i historia. */
.queue-list-container.list-short {
max-height: 250px;
}
.glass-card-subtle {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-glass);
border-radius: 14px;
}
/* Pasek przesyłania nie zna postępu, więc animujemy go w nieskończoność. */
.progress-bar-fill.indeterminate {
width: 40%;
background: var(--primary);
animation: upload-slide 1.1s ease-in-out infinite;
}
@keyframes upload-slide {
0% { margin-left: 0; }
50% { margin-left: 60%; }
100% { margin-left: 0; }
}
.main-header {
width: 100%;
}
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Plus, PlusCircle, Search } from 'lucide-vue-next'
import { api } from '@/api/client'
import { useToasts } from '@/composables/useToasts'
import type { BotStatus } from '@/types/status'
const props = defineProps<{ status: BotStatus | null }>()
const { notify, run } = useToasts()
const query = ref('')
const busy = ref(false)
async function submit() {
const trimmed = query.value.trim()
if (!trimmed) return
if (!props.status?.active_voice_channel) {
notify('Bot musi być połączony z kanałem głosowym!', 'error')
return
}
busy.value = true
const added = await run(() => api.play(trimmed), 'Dodano utwór do kolejki!', 'Błąd dodawania')
if (added) query.value = ''
busy.value = false
}
</script>
<template>
<div class="glass-card add-track-panel">
<h2 class="card-title"><PlusCircle /> Dodaj do kolejki</h2>
<form class="add-form" @submit.prevent="submit">
<div class="input-with-icon">
<Search class="input-icon" />
<input
v-model="query"
type="text"
class="premium-input"
placeholder="Wklej link YouTube lub wpisz wyszukiwanie..."
:disabled="busy"
required
/>
</div>
<button type="submit" class="btn btn-primary" :disabled="busy">
<span v-if="busy" class="spinner"></span>
<span v-else><Plus /> Dodaj</span>
</button>
</form>
</div>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Music, Settings } from 'lucide-vue-next'
import type { BotStatus } from '@/types/status'
const props = defineProps<{ status: BotStatus | null }>()
defineEmits<{ openSettings: [] }>()
const activeChannel = computed(() => props.status?.active_voice_channel ?? null)
const label = computed(() =>
activeChannel.value
? `Połączony: ${activeChannel.value.channel_name} (${activeChannel.value.guild_name})`
: 'Rozłączony',
)
</script>
<template>
<header class="main-header">
<div class="logo-area">
<div class="logo-icon"><Music /></div>
<div class="logo-text">
<h1>ŻyrarBOT</h1>
<span class="subtitle">Kontroler Web</span>
</div>
</div>
<div class="header-actions">
<button class="btn btn-secondary" @click="$emit('openSettings')">
<Settings /> Ustawienia
</button>
<div class="bot-status-badge">
<span class="status-indicator" :class="activeChannel ? 'online' : 'offline'"></span>
<span>{{ label }}</span>
</div>
</div>
</header>
</template>
<style scoped>
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.btn-secondary {
display: flex;
align-items: center;
gap: 8px;
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Clock, History, Plus, RotateCcw } from 'lucide-vue-next'
import { api } from '@/api/client'
import { useToasts } from '@/composables/useToasts'
import { useUploads } from '@/composables/useUploads'
import { formatTime } from '@/composables/useTrackProgress'
import { tracksLabel } from '@/utils/plural'
import type { BotStatus, HistoryTrack } from '@/types/status'
const props = defineProps<{ status: BotStatus | null }>()
const { notify, run } = useToasts()
const { enqueue } = useUploads()
const history = computed(() => props.status?.history ?? [])
async function replay(entry: HistoryTrack) {
if (!props.status?.active_voice_channel) {
notify('Bot musi być połączony z kanałem głosowym!', 'error')
return
}
if (entry.is_local) {
const path = entry.filepath ?? entry.url ?? entry.title
await enqueue(path.split(/[\\/]/).pop() ?? path)
return
}
if (!entry.url) {
notify('Ten wpis historii nie ma źródła do odtworzenia', 'error')
return
}
await run(() => api.play(entry.url as string), 'Dodano z historii do kolejki!', 'Błąd')
}
</script>
<template>
<div class="glass-card history-panel">
<div class="queue-header">
<h2 class="card-title"><History /> Historia odtwarzania</h2>
<span class="queue-count">{{ tracksLabel(history.length) }}</span>
</div>
<div class="queue-list-container list-short">
<ul class="queue-list">
<li v-for="entry in history" :key="entry.id" class="queue-item">
<div class="queue-index"><RotateCcw /></div>
<div class="queue-track-info">
<div class="queue-track-title" :title="entry.title">{{ entry.title }}</div>
<div class="queue-track-meta">
{{ entry.duration ? formatTime(entry.duration) : '--:--' }}
<template v-if="entry.is_local"> • plik lokalny</template>
</div>
</div>
<div class="queue-item-actions">
<button
class="btn btn-secondary btn-compact"
title="Dodaj ponownie do kolejki"
@click="replay(entry)"
>
<Plus /> Dodaj
</button>
</div>
</li>
<div v-if="!history.length" class="empty-queue-message">
<Clock />
<p>Historia odtwarzania jest pusta.</p>
</div>
</ul>
</div>
</div>
</template>
<style scoped>
.queue-track-info {
flex: 1;
min-width: 0;
}
.queue-track-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { X } from 'lucide-vue-next'
defineProps<{ wide?: boolean }>()
const emit = defineEmits<{ close: [] }>()
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') emit('close')
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal-card glass-card" :class="{ wide }">
<div class="modal-header">
<h2 class="card-title"><slot name="title" /></h2>
<button class="btn-icon" title="Zamknij" @click="$emit('close')"><X /></button>
</div>
<div class="modal-body">
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
display: flex;
}
.modal-card.wide {
max-width: 650px;
}
</style>
+189
View File
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { computed, ref, toRef } from 'vue'
import {
ExternalLink,
Music4,
Pause,
Play,
Repeat,
Repeat1,
SkipForward,
Square,
Volume,
Volume1,
Volume2,
VolumeX,
} from 'lucide-vue-next'
import { api } from '@/api/client'
import { useToasts } from '@/composables/useToasts'
import { formatTime, useTrackProgress } from '@/composables/useTrackProgress'
import { loopModeOf, type BotStatus } from '@/types/status'
const props = defineProps<{ status: BotStatus | null }>()
const { notify, run } = useToasts()
const { elapsed, duration, percent } = useTrackProgress(toRef(props, 'status'))
const current = computed(() => props.status?.current ?? null)
const playing = computed(() => props.status?.playing ?? false)
const loopMode = computed(() => loopModeOf(props.status))
/** Podczas przeciągania suwaka pokazujemy wartość lokalną, nie tę z serwera. */
const draggedVolume = ref<number | null>(null)
const volume = computed(() => draggedVolume.value ?? props.status?.volume ?? 100)
const volumeIcon = computed(() => {
if (volume.value === 0) return VolumeX
if (volume.value < 35) return Volume
if (volume.value < 75) return Volume1
return Volume2
})
const loopButton = computed(() => {
switch (loopMode.value) {
case 'single':
return {
icon: Repeat1,
title: 'Zapętlenie: jeden utwór (kliknij, aby wyłączyć)',
badge: 'Zapętlenie utworu',
}
case 'playlist':
return {
icon: Repeat,
title: 'Zapętlenie: cała kolejka (kliknij, aby zapętlić jeden utwór)',
badge: 'Zapętlenie kolejki',
}
default:
return {
icon: Repeat,
title: 'Zapętlenie: wyłączone (kliknij, aby zapętlić kolejkę)',
badge: null,
}
}
})
function onVolumeInput(event: Event) {
draggedVolume.value = Number((event.target as HTMLInputElement).value)
}
async function commitVolume() {
const value = draggedVolume.value
if (value === null) return
await run(() => api.setVolume(value), '', 'Nie udało się zmienić głośności')
draggedVolume.value = null
}
const togglePlayback = () =>
playing.value
? run(() => api.pause(), 'Wstrzymano odtwarzanie', 'Błąd sterowania odtwarzaniem')
: run(() => api.resume(), 'Wznowiono odtwarzanie', 'Błąd sterowania odtwarzaniem')
const skip = () => run(() => api.skip(), 'Pominięto utwór', 'Błąd pomijania utworu')
const stop = () =>
run(() => api.stop(), 'Zatrzymano odtwarzanie i wyczyszczono kolejkę', 'Błąd zatrzymywania')
const LOOP_MESSAGES = {
playlist: 'Zapętlanie kolejki włączone',
single: 'Zapętlanie utworu włączone',
off: 'Zapętlanie wyłączone',
} as const
async function cycleLoop() {
try {
const { mode } = await api.cycleLoop()
notify(LOOP_MESSAGES[mode], 'info')
} catch {
notify('Błąd ustawiania pętli', 'error')
}
}
</script>
<template>
<div class="glass-card player-panel">
<div class="card-header-actions">
<span class="badge badge-live">Live</span>
</div>
<div class="visualizer-container">
<div class="vinyl-record" :class="{ playing }">
<div class="vinyl-center"></div>
<div class="vinyl-grooves"></div>
<div class="vinyl-glow"></div>
<div class="vinyl-icon"><Music4 /></div>
</div>
<div class="music-wave" :class="{ active: playing }">
<span v-for="bar in 5" :key="bar"></span>
</div>
</div>
<div class="now-playing-info">
<h3 class="track-title">{{ current?.title ?? 'Nic nie jest odtwarzane' }}</h3>
<p class="track-artist">
{{ current ? (current.url ? 'Źródło: YouTube' : 'Źródło: plik lokalny') : 'Połącz bota i dodaj utwór' }}
</p>
<a v-if="current?.url" :href="current.url" target="_blank" class="track-link-btn">
<ExternalLink /> Otwórz w YouTube
</a>
</div>
<div v-if="current" class="track-progress-container">
<span class="time-text">{{ formatTime(elapsed) }}</span>
<div class="progress-bar-bg">
<div class="progress-bar-fill" :style="{ width: `${percent}%` }"></div>
</div>
<span class="time-text">{{ duration > 0 ? formatTime(duration) : 'Live' }}</span>
</div>
<div class="playback-state-bar">
<span v-if="loopButton.badge" class="badge">
<component :is="loopButton.icon" /> {{ loopButton.badge }}
</span>
</div>
<div class="player-controls">
<button class="btn-icon btn-secondary" title="Zatrzymaj i wyczyść" @click="stop">
<Square />
</button>
<button
class="btn-icon btn-primary"
:title="playing ? 'Wstrzymaj' : 'Odtwarzaj'"
@click="togglePlayback"
>
<component :is="playing ? Pause : Play" />
</button>
<button class="btn-icon btn-secondary" title="Pomiń utwór" @click="skip">
<SkipForward />
</button>
<button
class="btn-icon btn-secondary loop-button"
:class="{ active: loopMode !== 'off' }"
:title="loopButton.title"
@click="cycleLoop"
>
<component :is="loopButton.icon" />
</button>
</div>
<div class="volume-container">
<button class="btn-icon-small" title="Głośność"><component :is="volumeIcon" /></button>
<input
type="range"
class="premium-slider"
min="0"
max="100"
:value="volume"
@input="onVolumeInput"
@change="commitVolume"
/>
<span class="volume-value">{{ volume }}%</span>
</div>
</div>
</template>
<style scoped>
.loop-button.active {
background: var(--primary);
color: var(--bg-card, var(--bg-dark));
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ArrowDown, ArrowUp, GripVertical, Minus, Play, Plus, Repeat, Trash2, Volume2 } from 'lucide-vue-next'
import type { QueueTrack } from '@/types/status'
const MIN_PLAYS = 1
const MAX_PLAYS = 999
const props = defineProps<{
track: QueueTrack
index: number
queueIndex: number
dragging: boolean
dropTarget: boolean
}>()
const emit = defineEmits<{
jump: [index: number]
moveUp: [index: number]
moveDown: [index: number]
remove: [id: string]
setPlays: [id: string, plays: number]
invalidPlays: []
}>()
// Lokalna kopia pozwala pisać w polu bez walki z aktualizacjami z WebSocketu.
const plays = ref(props.track.plays_left)
const editing = ref(false)
watch(
() => props.track.plays_left,
(value) => {
if (!editing.value) plays.value = value
},
)
function step(delta: number) {
const next = Math.min(MAX_PLAYS, Math.max(MIN_PLAYS, props.track.plays_left + delta))
if (next !== props.track.plays_left) emit('setPlays', props.track.id, next)
}
function commit() {
editing.value = false
const value = Number(plays.value)
if (!Number.isInteger(value) || value < MIN_PLAYS || value > MAX_PLAYS) {
plays.value = props.track.plays_left
emit('invalidPlays')
return
}
if (value !== props.track.plays_left) emit('setPlays', props.track.id, value)
}
</script>
<template>
<li
class="queue-item"
:class="{
'playing-item': index === queueIndex,
'played-item': index < queueIndex,
dragging,
'drag-over': dropTarget,
}"
draggable="true"
>
<div class="drag-handle" title="Przeciągnij, aby zmienić kolejność"><GripVertical /></div>
<div v-if="index === queueIndex" class="queue-index" title="Aktualnie odtwarzane">
<Volume2 class="playing-icon" />
</div>
<div v-else class="queue-index">{{ index + 1 }}</div>
<div class="queue-track-info">
<div class="queue-track-title" :title="track.title">{{ track.title }}</div>
<div class="track-plays-control">
<Repeat class="plays-icon" />
<span class="plays-label" title="Pozostało odtworzeń z kolejki">Pozostało:</span>
<button class="btn-tiny-action" title="Zmniejsz o 1" @click="step(-1)"><Minus /></button>
<input
v-model.number="plays"
type="number"
class="track-plays-input"
:min="MIN_PLAYS"
:max="MAX_PLAYS"
@focus="editing = true"
@blur="commit"
@keyup.enter="($event.target as HTMLInputElement).blur()"
/>
<button class="btn-tiny-action" title="Zwiększ o 1" @click="step(1)"><Plus /></button>
</div>
</div>
<div class="queue-item-actions">
<button class="btn-action-icon" title="Odtwarzaj od tej pozycji" @click="$emit('jump', index)">
<Play />
</button>
<button class="btn-action-icon" title="Przesuń w górę" @click="$emit('moveUp', index)">
<ArrowUp />
</button>
<button class="btn-action-icon" title="Przesuń w dół" @click="$emit('moveDown', index)">
<ArrowDown />
</button>
<button class="btn-action-icon delete" title="Usuń z kolejki" @click="$emit('remove', track.id)">
<Trash2 />
</button>
</div>
</li>
</template>
<style scoped>
.playing-icon {
color: var(--primary);
}
.track-plays-control {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
background: rgba(255, 255, 255, 0.06);
padding: 3px 10px;
border-radius: 12px;
margin-top: 6px;
width: fit-content;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.plays-icon {
width: 13px;
height: 13px;
opacity: 0.9;
color: var(--primary);
}
.plays-label {
color: var(--text-muted);
}
.btn-tiny-action {
background: rgba(255, 255, 255, 0.12);
border: none;
color: inherit;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
transition: background 0.2s;
display: inline-flex;
}
.btn-tiny-action:hover {
background: rgba(255, 255, 255, 0.24);
}
.btn-tiny-action :deep(svg) {
width: 11px;
height: 11px;
}
.track-plays-input {
width: 48px;
text-align: center;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 6px;
padding: 2px 4px;
font-size: 0.8rem;
font-weight: bold;
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Disc, ListMusic } from 'lucide-vue-next'
import QueueItem from '@/components/QueueItem.vue'
import { api } from '@/api/client'
import { useBotStatus } from '@/composables/useBotStatus'
import { useToasts } from '@/composables/useToasts'
import { tracksLabel } from '@/utils/plural'
import type { BotStatus } from '@/types/status'
const props = defineProps<{ status: BotStatus | null }>()
const { notify, run } = useToasts()
const { paused } = useBotStatus()
const queue = computed(() => props.status?.queue ?? [])
const queueIndex = computed(() => props.status?.queue_index ?? -1)
const draggedId = ref<string | null>(null)
const dropTargetId = ref<string | null>(null)
const sendOrder = (ids: string[]) =>
run(() => api.reorder(ids), 'Zmieniono kolejność utworów!', 'Nie udało się zapisać kolejności')
function reordered(from: number, to: number): string[] {
const ids = queue.value.map((track) => track.id)
const [moved] = ids.splice(from, 1)
ids.splice(to, 0, moved)
return ids
}
function onDragStart(id: string) {
draggedId.value = id
// Wstrzymujemy aktualizacje z serwera, aby lista nie przeskakiwała pod kursorem.
paused.value = true
}
function onDragEnter(id: string) {
if (id !== draggedId.value) dropTargetId.value = id
}
async function onDrop(targetIndex: number) {
const from = queue.value.findIndex((track) => track.id === draggedId.value)
dropTargetId.value = null
if (from !== -1 && from !== targetIndex) {
await sendOrder(reordered(from, targetIndex))
}
onDragEnd()
}
function onDragEnd() {
draggedId.value = null
dropTargetId.value = null
paused.value = false
}
function moveUp(index: number) {
if (index > 0) sendOrder(reordered(index, index - 1))
}
function moveDown(index: number) {
if (index < queue.value.length - 1) sendOrder(reordered(index, index + 1))
}
const jump = (index: number) =>
run(() => api.jump(index), 'Zmieniono odtwarzany utwór', 'Nie udało się zmienić utworu')
const remove = (id: string) =>
run(() => api.remove(id), 'Usunięto utwór z kolejki', 'Nie udało się usunąć utworu')
const setPlays = (id: string, plays: number) =>
run(() => api.setTrackPlays(id, plays), '', 'Błąd aktualizacji odtworzeń')
const warnInvalidPlays = () => notify('Wartość musi być z przedziału 1-999', 'error')
</script>
<template>
<div class="glass-card queue-panel">
<div class="queue-header">
<h2 class="card-title"><ListMusic /> Kolejka odtwarzania</h2>
<span class="queue-count">{{ tracksLabel(queue.length) }}</span>
</div>
<div class="queue-list-container">
<ul class="queue-list">
<QueueItem
v-for="(track, index) in queue"
:key="track.id"
:track="track"
:index="index"
:queue-index="queueIndex"
:dragging="draggedId === track.id"
:drop-target="dropTargetId === track.id"
@dragstart="onDragStart(track.id)"
@dragenter="onDragEnter(track.id)"
@dragover.prevent
@dragleave="dropTargetId = null"
@drop.prevent="onDrop(index)"
@dragend="onDragEnd"
@jump="jump"
@move-up="moveUp"
@move-down="moveDown"
@remove="remove"
@set-plays="setPlays"
@invalid-plays="warnInvalidPlays"
/>
<div v-if="!queue.length" class="empty-queue-message">
<Disc />
<p>Kolejka jest pusta. Dodaj coś!</p>
</div>
</ul>
</div>
</div>
</template>
+151
View File
@@ -0,0 +1,151 @@
<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>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { AlertTriangle, CheckCircle2, Info } from 'lucide-vue-next'
import { useToasts, type ToastType } from '@/composables/useToasts'
const { toasts } = useToasts()
const icons = {
success: CheckCircle2,
error: AlertTriangle,
info: Info,
} satisfies Record<ToastType, unknown>
</script>
<template>
<div class="toast-container">
<TransitionGroup name="toast">
<div v-for="toast in toasts" :key="toast.id" class="toast" :class="toast.type">
<component :is="icons[toast.type]" />
<span>{{ toast.message }}</span>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(-20px);
}
.toast-enter-active,
.toast-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
</style>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { FileAudio, Plus, Trash2 } from 'lucide-vue-next'
import type { UploadedFile } from '@/types/status'
defineProps<{ file: UploadedFile }>()
defineEmits<{ enqueue: [filename: string]; remove: [filename: string] }>()
</script>
<template>
<li class="queue-item">
<div class="queue-index"><FileAudio /></div>
<div class="queue-track-info">
<div class="queue-track-title" :title="file.filename">{{ file.filename }}</div>
<div class="queue-track-meta">{{ file.size_mb }} MB</div>
</div>
<div class="queue-item-actions">
<button class="btn btn-primary btn-compact" title="Dodaj do kolejki" @click="$emit('enqueue', file.filename)">
<Plus /> Dodaj
</button>
<button class="btn-action-icon delete" title="Usuń z serwera" @click="$emit('remove', file.filename)">
<Trash2 />
</button>
</div>
</li>
</template>
<style scoped>
.queue-track-info {
flex: 1;
min-width: 0;
}
.queue-track-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-item-actions {
display: flex;
gap: 6px;
align-items: center;
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { FolderOpen, FolderUp } from 'lucide-vue-next'
import ModalShell from '@/components/ModalShell.vue'
import UploadedFileRow from '@/components/UploadedFileRow.vue'
import { useUploads } from '@/composables/useUploads'
import { useToasts } from '@/composables/useToasts'
import type { BotStatus } from '@/types/status'
const props = defineProps<{ status: BotStatus | null }>()
defineEmits<{ close: [] }>()
const { files, remove, enqueue } = useUploads()
const { notify } = useToasts()
function play(filename: string) {
if (!props.status?.active_voice_channel) {
notify('Bot musi być połączony z kanałem głosowym!', 'error')
return
}
enqueue(filename)
}
</script>
<template>
<ModalShell wide @close="$emit('close')">
<template #title><FolderUp /> Wszystkie pliki na serwerze</template>
<p class="settings-desc">
Pełna lista plików zapisanych w serwerowej bazie danych (posortowana od ostatnio używanych /
przesłanych).
</p>
<ul class="queue-list">
<UploadedFileRow
v-for="file in files"
:key="file.filename"
:file="file"
@enqueue="play"
@remove="remove"
/>
<div v-if="!files.length" class="empty-queue-message">
<FolderOpen />
<p>Brak przesłanych plików na serwerze.</p>
</div>
</ul>
</ModalShell>
</template>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { FolderOpen, FolderUp, UploadCloud } from 'lucide-vue-next'
import UploadedFileRow from '@/components/UploadedFileRow.vue'
import { DASHBOARD_FILE_LIMIT, useUploads } from '@/composables/useUploads'
import { useToasts } from '@/composables/useToasts'
import { filesLabel } from '@/utils/plural'
import type { BotStatus } from '@/types/status'
const props = defineProps<{ status: BotStatus | null }>()
defineEmits<{ openLibrary: [] }>()
const { files, uploading, uploadLabel, upload, remove, enqueue } = useUploads()
const { notify } = useToasts()
const fileInput = ref<HTMLInputElement | null>(null)
const recent = computed(() => files.value.slice(0, DASHBOARD_FILE_LIMIT))
async function onFilesPicked(event: Event) {
const input = event.target as HTMLInputElement
if (input.files?.length) await upload(input.files)
input.value = ''
}
function play(filename: string) {
if (!props.status?.active_voice_channel) {
notify('Bot musi być połączony z kanałem głosowym!', 'error')
return
}
enqueue(filename)
}
</script>
<template>
<div class="glass-card uploads-panel">
<div class="queue-header">
<h2 class="card-title"><FolderUp /> Serwerowa baza plików</h2>
<span class="queue-count">{{ filesLabel(files.length) }}</span>
</div>
<div class="upload-area">
<input ref="fileInput" type="file" hidden multiple @change="onFilesPicked" />
<button class="btn btn-secondary btn-block" :disabled="uploading" @click="fileInput?.click()">
<UploadCloud /> Prześlij plik wideo/audio na serwer
</button>
<div v-if="uploading" class="upload-progress">
<div class="progress-bar-bg">
<div class="progress-bar-fill indeterminate"></div>
</div>
<span class="time-text">{{ uploadLabel }}</span>
</div>
</div>
<div class="queue-list-container list-short">
<ul class="queue-list">
<UploadedFileRow
v-for="file in recent"
:key="file.filename"
:file="file"
@enqueue="play"
@remove="remove"
/>
<div v-if="!files.length" class="empty-queue-message">
<FolderOpen />
<p>Brak przesłanych plików na serwerze.</p>
</div>
</ul>
</div>
<button class="btn btn-secondary btn-block" @click="$emit('openLibrary')">
<FolderOpen /> Pokaż wszystkie pliki na serwerze ({{ files.length }})
</button>
</div>
</template>
<style scoped>
.upload-area {
margin-bottom: 15px;
}
.upload-progress {
margin-top: 10px;
text-align: center;
}
.upload-progress .time-text {
display: block;
margin-top: 4px;
}
.btn-block:last-of-type {
margin-top: 10px;
}
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { LogIn, LogOut, Radio } from 'lucide-vue-next'
import { api } from '@/api/client'
import { useToasts } from '@/composables/useToasts'
import type { BotStatus } from '@/types/status'
const props = defineProps<{ status: BotStatus | null }>()
const { notify, run } = useToasts()
const selected = ref('')
const channels = computed(() => props.status?.voice_channels ?? [])
const active = computed(() => props.status?.active_voice_channel ?? null)
// Wybór podąża za realnym połączeniem, a bez niego domyślnie wskazuje pierwszy kanał.
watch(
[active, channels],
([activeChannel, available]) => {
if (activeChannel) {
selected.value = activeChannel.channel_id
} else if (!available.some((channel) => channel.channel_id === selected.value)) {
selected.value = available[0]?.channel_id ?? ''
}
},
{ immediate: true },
)
async function toggle() {
if (active.value) {
await run(() => api.disconnect(), 'Rozłączono z kanału głosowego', 'Błąd rozłączania')
return
}
if (!selected.value) {
notify('Wybierz kanał głosowy z listy!', 'error')
return
}
await run(() => api.connect(selected.value), 'Połączono z kanałem głosowym!', 'Nie udało się połączyć')
}
</script>
<template>
<div class="glass-card connection-panel">
<h2 class="card-title"><Radio /> Kanał Głosowy</h2>
<div class="control-row">
<select v-model="selected" class="premium-select" :disabled="!channels.length">
<option v-if="!channels.length" value="">Brak dostępnych kanałów</option>
<option v-for="channel in channels" :key="channel.channel_id" :value="channel.channel_id">
{{ channel.guild_name }} - {{ channel.channel_name }}
</option>
</select>
<button class="btn" :class="active ? 'btn-danger' : 'btn-primary btn-glow'" @click="toggle">
<component :is="active ? LogOut : LogIn" />
{{ active ? 'Odłącz' : 'Połącz' }}
</button>
</div>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from '@/App.vue'
import '@/assets/style.css'
createApp(App).mount('#app')