More things

This commit is contained in:
2026-10-01 02:29:21 +02:00
parent e1df4d3ae1
commit 13b659662e
54 changed files with 4409 additions and 911 deletions

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
/* The wiki's back office: every account it holds, in one list, together with
what each of them may do. The sidebar offers the door to an admin alone;
whoever else reaches this address is turned away before anything is asked —
a member sees the same refusal whether or not the list would have answered.
The changes members propose are kept on their own page beside this one. */
import type { AuthRole } from '~/composables/useAuth'
export interface AdminAccount {
id: string
username: string
avatar: string
role: AuthRole
createdAt: string
}
/* matches the home page's transition so the two surfaces slide past
each other the way the reader does (see main.scss) */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const { user, loggedIn, isAdmin, authHeaders, signOut } = useAuth()
const { show: openSignIn } = useSignInPopup()
const { t, locale } = useI18n()
/* What the last request could not do, in the words to show for it. '' when
there is nothing to report: a dead session is signed out of instead of
explained, the same way the profile popup handles one. Shared state rather
than a ref, so the first draw in the browser agrees with the one the server
already wrote out. */
const failure = useState('admin:failure', () => '')
function describeFailure(err: any): string {
const code = err?.statusCode ?? err?.response?.status
if (code === 401) {
signOut()
if (import.meta.client) openSignIn({ notice: t('admin.sessionExpired') })
return ''
}
return err?.data?.message || t('common.apiUnreachable')
}
/* Only an admin's session is answered here, so nothing is asked until the
session is known to be one — signing in over this page brings the list with
it, which is what the watch below is for. */
const {
data: accounts,
status,
refresh,
} = await useAsyncData(
'admin-accounts',
async () => {
failure.value = ''
if (!isAdmin.value) return []
try {
const { accounts: list } = await $fetch<{ accounts: AdminAccount[] }>(
`${api}/admin/users`,
{ headers: authHeaders() }
)
return list
} catch (err: any) {
failure.value = describeFailure(err)
return []
}
},
{ default: () => [] }
)
watch(isAdmin, (admin) => {
if (admin) void refresh()
})
const loading = computed(() => status.value === 'pending')
const adminCount = computed(() => accounts.value.filter((a) => a.role === 'admin').length)
/* --- handing the role on, taking it back, closing an account ---
One change at a time: while a request is in flight the menu waits, so two
clicks cannot disagree about what an account is. */
const changingId = ref('')
const removing = ref(false)
const actionError = ref('')
async function setRole(account: AdminAccount, role: AuthRole) {
if (changingId.value || removing.value) return
actionError.value = ''
changingId.value = account.id
try {
const { account: updated } = await $fetch<{ account: AdminAccount }>(
`${api}/admin/users/${account.id}/role`,
{ method: 'PUT', headers: authHeaders(), body: { role } }
)
/* the whole list is written again rather than the one row changed in
place: useAsyncData keeps it shallow, so a row would not notice */
accounts.value = accounts.value.map((a) => (a.id === updated.id ? updated : a))
} catch (err: any) {
actionError.value = describeFailure(err) || t('admin.roleChangeFailed')
} finally {
changingId.value = ''
}
}
/* Deleting is the one thing on this page that does not come back, so the menu
hands it to the yes/no popup first: the account awaiting an answer, null
when nothing is. */
const removalTarget = ref<AdminAccount | null>(null)
async function removeAccount() {
const account = removalTarget.value
if (!account || removing.value || changingId.value) return
actionError.value = ''
removing.value = true
try {
await $fetch(`${api}/admin/users/${account.id}`, {
method: 'DELETE',
headers: authHeaders()
})
accounts.value = accounts.value.filter((a) => a.id !== account.id)
removalTarget.value = null
} catch (err: any) {
actionError.value = describeFailure(err) || t('admin.deleteFailed')
} finally {
removing.value = false
}
}
/* esc answers the yes/no popup the way its Cancel button does: the dashboard has
no other use for the key. */
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape' || !removalTarget.value || removing.value) return
removalTarget.value = null
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
/* "12 Mar 2026", in the interface language and read in UTC — the server and
the browser may run on different clocks, and the first render has to say the
same day on both. */
function joined(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat(locale.value, {
timeZone: 'UTC',
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}
useSeoMeta({
title: () => t('admin.metaTitle'),
description: () => t('admin.metaDescription'),
})
</script>
<template>
<div class="home-main">
<!-- Nobody but an admin is given the list: a visitor without an account is
offered the way in, a signed-in member is told the room is not theirs. -->
<AuthGate
v-if="!loggedIn"
:heading="t('admin.gateHeading')"
:text="t('admin.gateText')"
/>
<section v-else-if="!isAdmin" class="auth-gate" aria-labelledby="admin-no-access">
<span class="auth-gate-mark" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4.5 8-10V5.5L12 2 4 5.5V12c0 5.5 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</svg>
</span>
<h1 id="admin-no-access" class="auth-gate-title">{{ t('admin.forAdmins') }}</h1>
<p class="auth-gate-text">
{{ t('admin.forAdminsText') }}
</p>
<div class="auth-gate-actions">
<NuxtLink to="/articles" class="btn btn-ghost">{{ t('admin.backToArticles') }}</NuxtLink>
</div>
</section>
<template v-else>
<section class="hero">
<h1 v-html="t('admin.hero')"></h1>
</section>
<!-- Every account the wiki holds and what each may do. The requests
members send live on their own page, reached from the sidebar. -->
<section class="section">
<div v-if="failure" class="banner">
<span class="glyph">!</span>
<span>{{ failure }}</span>
</div>
<template v-else>
<div class="results-bar">
<h2>
<template v-if="loading">{{ t('common.loading') }}</template>
<template v-else>
{{ t('admin.accountCount', accounts.length) }}
</template>
</h2>
<span v-if="!loading && accounts.length" class="topic-tally">
{{ t('admin.adminTally', adminCount) }} ·
{{ t('admin.memberTally', accounts.length - adminCount) }}
</span>
</div>
<p v-if="actionError" class="form-error">{{ actionError }}</p>
<div v-if="loading" class="account-list">
<div v-for="i in 4" :key="i" class="skeleton skeleton-row" />
</div>
<ul v-else-if="accounts.length" class="account-list">
<li v-for="account in accounts" :key="account.id" class="account-entry">
<span class="account-avatar" aria-hidden="true">
<img v-if="account.avatar" :src="account.avatar" alt="" />
<template v-else>{{ account.username.charAt(0).toUpperCase() }}</template>
</span>
<span class="account-entry-name">
{{ account.username }}
<span v-if="account.id === user?.id" class="account-entry-you">{{ t('admin.you') }}</span>
</span>
<span class="role-pill" :class="{ 'is-admin': account.role === 'admin' }">
{{ account.role === 'admin' ? t('admin.roleAdmin') : t('admin.roleMember') }}
</span>
<span class="account-entry-since">{{ t('admin.joined', { date: joined(account.createdAt) }) }}</span>
<!-- an admin changing their own role, or closing their own account,
is what the API will not do — so the row says it where the ⋮
would go, instead of offering a menu that could only fail -->
<span v-if="account.id === user?.id" class="account-entry-note">
{{ t('admin.selfNote') }}
</span>
<AccountActionsMenu
v-else
class="account-entry-kebab"
:account-id="account.id"
:username="account.username"
:role="account.role"
:busy="changingId === account.id || removing"
@set-role="setRole(account, $event)"
@remove="removalTarget = account"
/>
</li>
</ul>
<div v-else class="empty">
<div class="glyph">∅</div>
<h3>{{ t('admin.emptyTitle') }}</h3>
<p>{{ t('admin.emptyText') }}</p>
</div>
</template>
</section>
</template>
<ConfirmModal
:open="removalTarget !== null"
:title="t('admin.confirmDeleteTitle')"
:message="removalTarget
? t('admin.confirmDeleteMessage', { username: removalTarget.username })
: ''"
:confirm-label="t('admin.confirmDeleteButton')"
:cancel-label="t('admin.confirmKeepButton')"
@confirm="removeAccount"
@cancel="removalTarget = null"
/>
</div>
</template>

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
/* The changes members want, kept on their own page: every request that waits
for whoever holds the role to apply it or refuse it, and the record of the
ones already answered. A member's save does not write — it arrives here.
The sidebar offers the door to an admin alone; whoever else reaches this
address is turned away before anything is asked — a member sees the same
refusal whether or not the pile would have answered. */
import type { AdminProposal } from '~/components/ProposalRow.vue'
/* matches the home page's transition so the two surfaces slide past
each other the way the reader does (see main.scss) */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const { loggedIn, isAdmin, authHeaders, signOut } = useAuth()
const { report: reportPending } = usePendingRequests()
const { show: openSignIn } = useSignInPopup()
const { t } = useI18n()
/* What the last request could not do, in the words to show for it. '' when
there is nothing to report: a dead session is signed out of instead of
explained, the same way the account page handles one. Shared state rather
than a ref, so the first draw in the browser agrees with the one the server
already wrote out. */
const failure = useState('requests:failure', () => '')
function describeFailure(err: any): string {
const code = err?.statusCode ?? err?.response?.status
if (code === 401) {
signOut()
if (import.meta.client) openSignIn({ notice: t('admin.sessionExpired') })
return ''
}
return err?.data?.message || t('common.apiUnreachable')
}
/* --- edit requests ------------------------------------------------------
A member's save does not write: it waits here for whoever holds the role to
apply it or refuse it. Decided requests stay as the record of the call. Only
an admin's session is answered here, so nothing is asked until the session is
known to be one — signing in over this page brings the pile with it, which is
what the watch below is for. */
const {
data: proposals,
status: proposalsStatus,
refresh: refreshProposals,
} = await useAsyncData(
'admin-proposals',
async () => {
failure.value = ''
if (!isAdmin.value) return []
try {
const { proposals: list } = await $fetch<{ proposals: AdminProposal[] }>(
`${api}/admin/proposals`,
{ headers: authHeaders() }
)
/* The desk holds the whole pile, so it lays the waiting count on the
sidebar's door too — approving or refusing moves it in step, without
the API being asked twice. */
reportPending(list.filter((p) => p.status === 'pending').length)
return list
} catch (err: any) {
failure.value = describeFailure(err)
return []
}
},
{ default: () => [] }
)
watch(isAdmin, (admin) => {
if (admin) void refreshProposals()
})
const proposalsLoading = computed(() => proposalsStatus.value === 'pending')
const pendingProposals = computed(() =>
proposals.value.filter((p) => p.status === 'pending')
)
const decidedProposals = computed(() =>
proposals.value.filter((p) => p.status !== 'pending')
)
/* deciding, one request at a time — while a request is in flight the buttons
wait, so two clicks cannot decide the same request twice */
const decidingId = ref('')
const proposalError = ref('')
const expandedProposal = ref('')
const historyOpen = ref(false)
async function decideProposal(p: AdminProposal, action: 'approve' | 'reject') {
if (decidingId.value) return
proposalError.value = ''
decidingId.value = p.id
try {
await $fetch(`${api}/admin/proposals/${p.id}/${action}`, {
method: 'PUT',
headers: authHeaders(),
})
await refreshProposals()
} catch (err: any) {
proposalError.value =
describeFailure(err) ||
t(action === 'approve' ? 'admin.approveFailed' : 'admin.rejectFailed')
} finally {
decidingId.value = ''
}
}
useSeoMeta({
title: () => t('requests.metaTitle'),
description: () => t('requests.metaDescription'),
})
</script>
<template>
<div class="home-main">
<!-- Nobody but an admin is given the pile: a visitor without an account is
offered the way in, a signed-in member is told the room is not theirs. -->
<AuthGate
v-if="!loggedIn"
:heading="t('requests.gateHeading')"
:text="t('requests.gateText')"
/>
<section v-else-if="!isAdmin" class="auth-gate" aria-labelledby="requests-no-access">
<span class="auth-gate-mark" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4.5 8-10V5.5L12 2 4 5.5V12c0 5.5 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</svg>
</span>
<h1 id="requests-no-access" class="auth-gate-title">{{ t('admin.forAdmins') }}</h1>
<p class="auth-gate-text">
{{ t('requests.forAdminsText') }}
</p>
<div class="auth-gate-actions">
<NuxtLink to="/articles" class="btn btn-ghost">{{ t('admin.backToArticles') }}</NuxtLink>
</div>
</section>
<template v-else>
<section class="hero">
<h1 v-html="t('requests.hero')"></h1>
</section>
<!-- what members want changed: the waiting pile first, then the history
of what was answered. Details open the change as a pair of pages —
how it read when sent, how it would read if applied. -->
<section class="section">
<div v-if="failure" class="banner">
<span class="glyph">!</span>
<span>{{ failure }}</span>
</div>
<template v-else>
<div class="results-bar">
<h2 id="proposals-heading">
<template v-if="proposalsLoading">{{ t('common.loading') }}</template>
<template v-else>{{ t('admin.proposalsHeading') }}</template>
</h2>
<span v-if="!proposalsLoading && pendingProposals.length" class="topic-tally">
{{ t('admin.proposalWaitingTally', pendingProposals.length) }}
</span>
</div>
<p v-if="proposalError" class="form-error">{{ proposalError }}</p>
<div v-if="proposalsLoading" class="proposal-list">
<div v-for="i in 2" :key="i" class="skeleton proposal-skeleton" />
</div>
<div v-else-if="!proposals.length" class="empty">
<div class="glyph">✎</div>
<h3>{{ t('admin.proposalsEmptyTitle') }}</h3>
<p>{{ t('admin.proposalsEmptyText') }}</p>
</div>
<template v-else>
<ul v-if="pendingProposals.length" class="proposal-list">
<li v-for="p in pendingProposals" :key="p.id" class="proposal-entry is-pending">
<ProposalRow
:proposal="p"
:expanded="expandedProposal === p.id"
:busy="!!decidingId"
@toggle="expandedProposal = expandedProposal === p.id ? '' : p.id"
@decide="decideProposal(p, $event)"
/>
</li>
</ul>
<template v-if="decidedProposals.length">
<button
type="button"
class="proposal-history-toggle"
:aria-expanded="String(historyOpen)"
@click="historyOpen = !historyOpen"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
:class="{ turned: historyOpen }"
>
<path d="m9 18 6-6-6-6" />
</svg>
{{ t('admin.decidedHeading', decidedProposals.length) }}
</button>
<ul v-if="historyOpen" class="proposal-list">
<li v-for="p in decidedProposals" :key="p.id" class="proposal-entry" :class="`is-${p.status}`">
<ProposalRow
:proposal="p"
:expanded="expandedProposal === p.id"
:busy="false"
@toggle="expandedProposal = expandedProposal === p.id ? '' : p.id"
@decide="decideProposal(p, $event)"
/>
</li>
</ul>
</template>
</template>
</template>
</section>
</template>
</div>
</template>