All checks were successful
Build and Deploy Nuxt / build (push) Successful in 29s
228 lines
8.3 KiB
Vue
228 lines
8.3 KiB
Vue
<script setup lang="ts">
|
|
/* One request on the dashboard's list: who wants what, which parts of the page
|
|
it touches, and — while it waits — the buttons that answer it. Opening the
|
|
details lays the change out as a pair of pages: how the article read when the
|
|
request was sent, and how it would read with the change applied. A request
|
|
for a page the wiki does not have yet is laid out the same way against the
|
|
address it asks for, which is empty until somebody approves it. */
|
|
|
|
export interface ProposalArticle {
|
|
id: string
|
|
slug: string
|
|
identity: string
|
|
title: string
|
|
language: string
|
|
content: string
|
|
tags: string[]
|
|
}
|
|
|
|
export interface AdminProposal {
|
|
id: string
|
|
/** a change to a page, or a request for one that does not exist yet */
|
|
kind: 'edit' | 'create'
|
|
status: 'pending' | 'approved' | 'rejected'
|
|
createdAt: string
|
|
decidedAt: string | null
|
|
/** what a creation asks the page to live at, and which one it joins */
|
|
slug: string | null
|
|
baseSlug: string | null
|
|
proposed: { title: string; content: string; tags: string[]; language: string }
|
|
base: { title: string; content: string; tags: string[] }
|
|
proposer: { username: string; avatar: string }
|
|
decidedByUsername: string | null
|
|
article: ProposalArticle | null
|
|
}
|
|
|
|
const props = defineProps<{
|
|
proposal: AdminProposal
|
|
expanded: boolean
|
|
/** another row's decision is in flight: the buttons wait */
|
|
busy: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
toggle: []
|
|
decide: [action: 'approve' | 'reject']
|
|
}>()
|
|
|
|
const { t, locale } = useI18n()
|
|
|
|
/* "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 onDate(iso: string | null): string {
|
|
if (!iso) return ''
|
|
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)
|
|
}
|
|
|
|
const statusLabel = computed(() => {
|
|
if (props.proposal.status === 'approved') return t('admin.statusApproved')
|
|
if (props.proposal.status === 'rejected') return t('admin.statusRejected')
|
|
return t('admin.statusPending')
|
|
})
|
|
|
|
/* asking for a page the wiki does not have yet, rather than changing one */
|
|
const creating = computed(() => props.proposal.kind === 'create')
|
|
|
|
/* who is said to want what, in the tense the request's answer leaves behind */
|
|
const summaryLead = computed(() => {
|
|
const waiting = props.proposal.status === 'pending'
|
|
if (creating.value) return t(waiting ? 'admin.wantsToCreate' : 'admin.wantedToCreate')
|
|
return t(waiting ? 'admin.wantsToChange' : 'admin.wantedToChange')
|
|
})
|
|
|
|
const articleHref = computed(() => {
|
|
const a = props.proposal.article
|
|
return a ? versionHref(a.identity, a.language) : ''
|
|
})
|
|
|
|
/* where a creation would live: the address it asks for, in the shape a reader
|
|
follows — the language it is written in, over the article it names (a fresh
|
|
one names itself) */
|
|
const requestedPath = computed(() => {
|
|
const p = props.proposal
|
|
if (!p.slug) return ''
|
|
return versionHref(p.baseSlug || p.slug, p.proposed.language)
|
|
})
|
|
|
|
/* which parts of the page the request touches — enough to judge the row
|
|
without opening it; the text of the change waits in the details */
|
|
const changedFields = computed(() => {
|
|
const p = props.proposal
|
|
if (creating.value) return t('admin.newArticle')
|
|
const fields: string[] = []
|
|
if (p.base.title !== p.proposed.title) fields.push(t('admin.fieldTitle'))
|
|
if (p.base.content !== p.proposed.content) fields.push(t('admin.fieldContent'))
|
|
if (p.base.tags.join('\u0000') !== p.proposed.tags.join('\u0000')) fields.push(t('admin.fieldTags'))
|
|
return fields.join(' · ') || t('admin.fieldNone')
|
|
})
|
|
|
|
/* the article moved after the request was sent: applying the change would
|
|
overwrite whatever was written since, so the row warns about it. A request
|
|
for a new page cannot be overtaken — and once it is granted, the article it
|
|
brought about agrees with it by definition. */
|
|
const stale = computed(() => {
|
|
const p = props.proposal
|
|
if (creating.value || !p.article) return false
|
|
return p.article.title !== p.base.title || p.article.content !== p.base.content
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="proposal-row">
|
|
<span class="account-avatar" aria-hidden="true">
|
|
<img v-if="proposal.proposer.avatar" :src="proposal.proposer.avatar" alt="" />
|
|
<template v-else>{{ proposal.proposer.username.charAt(0).toUpperCase() }}</template>
|
|
</span>
|
|
|
|
<span class="proposal-summary">
|
|
<strong class="proposal-proposer">{{ proposal.proposer.username }}</strong>
|
|
{{ summaryLead }}
|
|
<!-- a granted creation has an article to point at; a waiting one has the
|
|
title it asked for and no page to walk to yet -->
|
|
<NuxtLink v-if="proposal.article" :to="articleHref" class="proposal-article-link">
|
|
{{ proposal.article.title }}
|
|
</NuxtLink>
|
|
<strong v-else-if="creating" class="proposal-new">{{ proposal.proposed.title }}</strong>
|
|
<em v-else>{{ t('admin.articleGone') }}</em>
|
|
<span v-if="creating" class="proposal-kind">{{ t('admin.newArticle') }}</span>
|
|
</span>
|
|
|
|
<span class="proposal-status" :class="`status-${proposal.status}`">{{ statusLabel }}</span>
|
|
|
|
<span class="proposal-date">{{ onDate(proposal.createdAt) }}</span>
|
|
|
|
<span v-if="proposal.status !== 'pending'" class="proposal-decided">
|
|
{{
|
|
t('admin.decidedBy', {
|
|
action: proposal.status === 'approved' ? t('admin.actionApproved') : t('admin.actionRejected'),
|
|
username: proposal.decidedByUsername ?? '—',
|
|
date: onDate(proposal.decidedAt),
|
|
})
|
|
}}
|
|
</span>
|
|
|
|
<span class="proposal-actions">
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-sm proposal-details-btn"
|
|
:aria-expanded="String(expanded)"
|
|
@click="emit('toggle')"
|
|
>
|
|
{{ expanded ? t('admin.hideDetails') : t('admin.details') }}
|
|
</button>
|
|
<template v-if="proposal.status === 'pending'">
|
|
<button
|
|
type="button"
|
|
class="btn btn-danger btn-sm"
|
|
:disabled="busy"
|
|
@click="emit('decide', 'reject')"
|
|
>
|
|
{{ t('admin.reject') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-approve btn-sm"
|
|
:disabled="busy"
|
|
@click="emit('decide', 'approve')"
|
|
>
|
|
{{ t('admin.approve') }}
|
|
</button>
|
|
</template>
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="expanded" class="proposal-diff">
|
|
<div class="proposal-diff-col">
|
|
<h4 class="diff-heading is-before">
|
|
{{ creating ? t('admin.asNewPage') : t('admin.asSent') }}
|
|
</h4>
|
|
<!-- a creation is asked for an address the wiki has nothing on: the
|
|
reviewer reads where the page would go instead of a page that is
|
|
not there -->
|
|
<template v-if="creating">
|
|
<p class="diff-title"><code>{{ requestedPath }}</code></p>
|
|
<div class="diff-body">
|
|
<p class="diff-absent">{{ t('admin.nothingHereYet') }}</p>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<p class="diff-title"><del>{{ proposal.base.title }}</del></p>
|
|
<p v-if="proposal.base.tags.length" class="diff-tags">
|
|
<span v-for="tag in proposal.base.tags" :key="tag" class="diff-tag">{{ tag }}</span>
|
|
</p>
|
|
<div class="diff-body">
|
|
<MarkdownView
|
|
:content="proposal.base.content || '*…*'"
|
|
:namespace="`proposal-${proposal.id}-base`"
|
|
:article-lang="proposal.proposed.language"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="proposal-diff-col">
|
|
<h4 class="diff-heading is-after">{{ t('admin.asProposed') }}</h4>
|
|
<p class="diff-title" :lang="proposal.proposed.language">
|
|
<strong>{{ proposal.proposed.title }}</strong>
|
|
</p>
|
|
<p v-if="proposal.proposed.tags.length" class="diff-tags">
|
|
<span v-for="tag in proposal.proposed.tags" :key="tag" class="diff-tag">{{ tag }}</span>
|
|
</p>
|
|
<div class="diff-body">
|
|
<MarkdownView
|
|
:content="proposal.proposed.content || '*…*'"
|
|
:namespace="`proposal-${proposal.id}-proposed`"
|
|
:article-lang="proposal.proposed.language"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|