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:
1 parent
119bff00a3
commit
038fa2ead4
16 files changed
+1245
No files matched your search
@@ -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>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from '@/App.vue'
|
||||
import '@/assets/style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
Reference in new issue
Block a user