Files
mathew/frontend/app/components/ProposalRow.vue
2026-10-01 02:29:21 +02:00

226 lines
8.1 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`"
/>
</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`"
/>
</div>
</div>
</div>
</template>