feat: server management page with log channel editing only for admins
This commit is contained in:
12 files changed
+236
-84
No files matched your search
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import AdminAccessPage from '@/components/AdminAccessPage.vue'
|
||||
import AdminServersPage from '@/components/AdminServersPage.vue'
|
||||
import AdminUsersPage from '@/components/AdminUsersPage.vue'
|
||||
|
||||
defineProps<{ username: string; userId: number }>()
|
||||
const currentPath = ref(window.location.pathname)
|
||||
const activePage = computed(() => currentPath.value.endsWith('/access') ? 'access' : 'users')
|
||||
const activePage = computed(() => currentPath.value.endsWith('/access')
|
||||
? 'access'
|
||||
: currentPath.value.endsWith('/servers') ? 'servers' : 'users')
|
||||
|
||||
function navigate(path: string) {
|
||||
window.history.pushState({}, '', path)
|
||||
@@ -38,13 +41,14 @@ async function logout() {
|
||||
<nav class="sidebar-nav" aria-label="Menu administratora">
|
||||
<a href="/admin/users" title="Użytkownicy" :class="{ active: activePage === 'users' }" @click.prevent="navigate('/admin/users')"><span class="nav-icon">♙</span>Użytkownicy</a>
|
||||
<a href="/admin/access" title="Dostęp do serwerów" :class="{ active: activePage === 'access' }" @click.prevent="navigate('/admin/access')"><span class="nav-icon">▦</span>Dostęp do serwerów</a>
|
||||
<a href="/admin/servers" title="Serwery" :class="{ active: activePage === 'servers' }" @click.prevent="navigate('/admin/servers')"><span class="nav-icon">#</span>Serwery</a>
|
||||
</nav>
|
||||
<div class="sidebar-bottom"><div class="admin-identity"><span class="identity-dot"/><span><small>Zalogowano jako</small><strong>{{ username }}</strong></span></div><button class="sidebar-logout" type="button" @click="logout">Wyloguj</button></div>
|
||||
</aside>
|
||||
|
||||
<section class="admin-main">
|
||||
<header class="admin-topbar"><div><span class="topbar-label">ADMINISTRACJA</span><span class="topbar-separator">/</span><span>{{ activePage === 'access' ? 'Dostęp do serwerów' : 'Użytkownicy' }}</span></div><div class="topbar-actions"><a class="topbar-link" href="/">Wróć do panelu</a><button class="admin-button admin-button-danger" type="button" @click="restartBot">Restart bota</button></div></header>
|
||||
<div class="admin-content"><AdminUsersPage v-if="activePage === 'users'" :current-user-id="userId"/><AdminAccessPage v-else/></div>
|
||||
<header class="admin-topbar"><div><span class="topbar-label">ADMINISTRACJA</span><span class="topbar-separator">/</span><span>{{ activePage === 'access' ? 'Dostęp do serwerów' : activePage === 'servers' ? 'Serwery' : 'Użytkownicy' }}</span></div><div class="topbar-actions"><a class="topbar-link" href="/">Wróć do panelu</a><button class="admin-button admin-button-danger" type="button" @click="restartBot">Restart bota</button></div></header>
|
||||
<div class="admin-content"><AdminUsersPage v-if="activePage === 'users'" :current-user-id="userId"/><AdminAccessPage v-else-if="activePage === 'access'"/><AdminServersPage v-else/></div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
type LogChannel = { channel_id: string; channel_name: string }
|
||||
type Server = {
|
||||
guild_id: string
|
||||
guild_name: string
|
||||
log_channel_id: string | null
|
||||
disabled: boolean
|
||||
channels: LogChannel[]
|
||||
}
|
||||
|
||||
const servers = ref<Server[]>([])
|
||||
const drafts = ref<Record<string, string>>({})
|
||||
const loading = ref(true)
|
||||
const saving = ref(new Set<string>())
|
||||
const errors = ref<Record<string, string>>({})
|
||||
const loadError = ref('')
|
||||
const changedIds = computed(() => servers.value
|
||||
.filter((server) => (drafts.value[server.guild_id] ?? '') !== (server.log_channel_id ?? ''))
|
||||
.map((server) => server.guild_id))
|
||||
|
||||
async function loadServers() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const response = await fetch('/api/admin/servers')
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new Error(payload?.detail ?? 'Nie udało się pobrać serwerów.')
|
||||
servers.value = payload
|
||||
drafts.value = Object.fromEntries(payload.map((server: Server) => [
|
||||
server.guild_id, server.disabled ? '' : server.log_channel_id ?? '',
|
||||
]))
|
||||
} catch (cause) {
|
||||
loadError.value = cause instanceof Error ? cause.message : 'Nie udało się pobrać serwerów.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(server: Server) {
|
||||
const guildId = server.guild_id
|
||||
saving.value = new Set(saving.value).add(guildId)
|
||||
errors.value = { ...errors.value, [guildId]: '' }
|
||||
try {
|
||||
const selected = drafts.value[guildId] ?? ''
|
||||
const response = await fetch(`/api/admin/servers/${guildId}/log-channel`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel_id: selected || null }),
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new Error(payload?.detail ?? 'Nie udało się zapisać kanału logów.')
|
||||
server.log_channel_id = payload.log_channel_id
|
||||
server.disabled = payload.disabled
|
||||
} catch (cause) {
|
||||
errors.value = {
|
||||
...errors.value,
|
||||
[guildId]: cause instanceof Error ? cause.message : 'Nie udało się zapisać kanału logów.',
|
||||
}
|
||||
} finally {
|
||||
const pending = new Set(saving.value)
|
||||
pending.delete(guildId)
|
||||
saving.value = pending
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { void loadServers() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page-content">
|
||||
<div class="admin-page-heading">
|
||||
<div><p class="admin-eyebrow">KONFIGURACJA</p><h2>Serwery</h2><p>Ustaw kanał, na który bot wysyła powiadomienia, lub wyłącz je dla serwera.</p></div>
|
||||
<button class="server-refresh" type="button" :disabled="loading" @click="loadServers">Odśwież</button>
|
||||
</div>
|
||||
<p v-if="loadError" class="server-error">{{ loadError }}</p>
|
||||
<div v-if="loading" class="server-state">Ładowanie serwerów…</div>
|
||||
<div v-else-if="!servers.length" class="server-state">Bot nie jest obecnie na żadnym serwerze Discorda.</div>
|
||||
<section v-for="server in servers" :key="server.guild_id" class="server-card">
|
||||
<header class="server-card-heading">
|
||||
<span class="server-icon">#</span>
|
||||
<div><h3>{{ server.guild_name }}</h3><p>{{ server.channels.length }} dostępnych kanałów tekstowych</p></div>
|
||||
<span class="server-state-badge" :class="drafts[server.guild_id] ? 'server-state-enabled' : 'server-state-disabled'">{{ drafts[server.guild_id] ? 'Logi włączone' : 'Logi wyłączone' }}</span>
|
||||
</header>
|
||||
<div class="server-settings-row">
|
||||
<label :for="`log-channel-${server.guild_id}`">Kanał logów</label>
|
||||
<select :id="`log-channel-${server.guild_id}`" v-model="drafts[server.guild_id]" class="server-channel-select" :disabled="saving.has(server.guild_id)">
|
||||
<option value="">Wyłącz kanał logów</option>
|
||||
<option v-for="channel in server.channels" :key="channel.channel_id" :value="channel.channel_id"># {{ channel.channel_name }}</option>
|
||||
</select>
|
||||
<button class="server-save" type="button" :disabled="saving.has(server.guild_id) || !changedIds.includes(server.guild_id)" @click="save(server)">{{ saving.has(server.guild_id) ? 'Zapisywanie…' : 'Zapisz' }}</button>
|
||||
</div>
|
||||
<p v-if="errors[server.guild_id]" class="server-error">{{ errors[server.guild_id] }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page-content{display:grid;gap:18px}.admin-page-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.admin-page-heading h2{margin:2px 0 6px;font-size:28px}.admin-page-heading p:last-child{margin:0;color:#a9b1c4}.admin-eyebrow{margin:0;color:#9c88ff;font-size:11px;font-weight:700;letter-spacing:.14em}.server-refresh,.server-save{min-height:38px;padding:0 15px;border:1px solid #41465c;border-radius:9px;background:#252a3c;color:#eceeff;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer}.server-refresh:hover:not(:disabled){background:#30364b}.server-save{border-color:#8c70f2;background:#7657e8;color:#fff}.server-save:hover:not(:disabled){background:#886df1}.server-refresh:disabled,.server-save:disabled{opacity:.45;cursor:not-allowed}.server-card{padding:22px 24px;border:1px solid #303449;border-radius:16px;background:#171a29;box-shadow:0 12px 36px #0003}.server-card-heading{display:flex;align-items:center;gap:12px;padding-bottom:18px;border-bottom:1px solid #2b2f42}.server-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:11px;background:#30294e;color:#beadff;font-size:20px}.server-card-heading h3{margin:0 0 3px;color:#f3f4f8;font-size:16px}.server-card-heading p{margin:0;color:#9299ac;font-size:12px}.server-state-badge{margin-left:auto;padding:6px 10px;border-radius:999px;font-size:11px}.server-state-enabled{background:#30294e;color:#cbbfff}.server-state-disabled{background:#282a35;color:#a6aabb}.server-settings-row{display:grid;grid-template-columns:140px minmax(220px,1fr) auto;align-items:center;gap:14px;padding-top:18px}.server-settings-row label{color:#c5cada;font-size:13px;font-weight:600}.server-channel-select{width:100%;height:40px;padding:0 11px;border:1px solid #41465c;border-radius:8px;background:#101321;color:#f3f4f8;font:inherit}.server-channel-select:focus{outline:2px solid #9b83ff66;border-color:#9b83ff}.server-state{padding:32px;color:#a9b1c4;text-align:center}.server-error{margin:12px 0 0;color:#ff8d9b;font-size:13px}@media(max-width:680px){.admin-page-heading{align-items:flex-start;flex-direction:column}.server-card{padding:17px}.server-settings-row{grid-template-columns:1fr}.server-settings-row label{margin-bottom:-7px}.server-save{width:100%}.server-state-badge{font-size:10px}}
|
||||
</style>
|
||||
@@ -9,13 +9,10 @@ const props = defineProps<{ status: BotStatus | null }>()
|
||||
|
||||
const { notify, run } = useToasts()
|
||||
const selected = ref('')
|
||||
const selectedNotification = ref('')
|
||||
const defaultPlays = ref(5)
|
||||
|
||||
const channels = computed(() => props.status?.voice_channels ?? [])
|
||||
const active = computed(() => props.status?.active_voice_channel ?? null)
|
||||
const notificationChannels = computed(() => props.status?.text_channels ?? [])
|
||||
const activeNotificationId = computed(() => props.status?.active_text_channel_id ?? null)
|
||||
|
||||
// Wybór podąża za realnym połączeniem, a bez niego domyślnie wskazuje pierwszy kanał.
|
||||
watch(
|
||||
@@ -38,18 +35,6 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[activeNotificationId, notificationChannels],
|
||||
([activeChannel, available]) => {
|
||||
if (activeChannel) {
|
||||
selectedNotification.value = activeChannel
|
||||
} else if (!available.some((channel) => channel.channel_id === selectedNotification.value)) {
|
||||
selectedNotification.value = available[0]?.channel_id ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function toggle() {
|
||||
if (active.value) {
|
||||
await run(() => api.disconnect(), '', 'Błąd rozłączania')
|
||||
@@ -62,15 +47,6 @@ async function toggle() {
|
||||
await run(() => api.connect(selected.value), '', 'Nie udało się połączyć')
|
||||
}
|
||||
|
||||
async function setNotificationChannel() {
|
||||
if (!selectedNotification.value) return
|
||||
await run(
|
||||
() => api.setNotificationChannel(selectedNotification.value),
|
||||
'Ustawiono kanał logów',
|
||||
'Nie udało się ustawić kanału logów',
|
||||
)
|
||||
}
|
||||
|
||||
async function saveDefaultPlays() {
|
||||
await run(
|
||||
() => api.setDefaultPlays(defaultPlays.value),
|
||||
@@ -114,30 +90,6 @@ async function saveDefaultPlays() {
|
||||
{{ active ? 'Odłącz' : 'Połącz' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="notification-channel-row">
|
||||
<label for="notification-channel">Kanał logów</label>
|
||||
<select
|
||||
id="notification-channel"
|
||||
v-model="selectedNotification"
|
||||
class="premium-select"
|
||||
:disabled="!notificationChannels.length"
|
||||
@change="setNotificationChannel"
|
||||
>
|
||||
<option
|
||||
v-if="!notificationChannels.length"
|
||||
value=""
|
||||
>
|
||||
Brak dostępnych kanałów tekstowych
|
||||
</option>
|
||||
<option
|
||||
v-for="channel in notificationChannels"
|
||||
:key="channel.channel_id"
|
||||
:value="channel.channel_id"
|
||||
>
|
||||
# {{ channel.channel_name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="default-plays-row">
|
||||
<label for="default-plays">Domyślny licznik</label>
|
||||
<input
|
||||
@@ -160,25 +112,6 @@ async function saveDefaultPlays() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.notification-channel-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 130px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.notification-channel-row .premium-select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.default-plays-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in new issue
Block a user