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,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>