Moderation dashboard (count tiles that jump to each queue; queue items as cards with icon, status and age) and timelines for the moderation activity and the admin record (a coloured dot per kind of action, relative times, filter chips on the record)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8d0a6aa36b
commit
86be5db6ea
@ -5,7 +5,7 @@ import Icon from '../../components/Icon.astro';
|
|||||||
import AdminNav from '../../components/AdminNav.astro';
|
import AdminNav from '../../components/AdminNav.astro';
|
||||||
import { asUser, COOKIE } from '../../lib/auth';
|
import { asUser, COOKIE } from '../../lib/auth';
|
||||||
import { ud } from '../../lib/urdu';
|
import { ud } from '../../lib/urdu';
|
||||||
import { ROLE, CONTENT, ACTION as DO } from '../../lib/roles';
|
import { ROLE, CONTENT, ACTION as DO, ago } from '../../lib/roles';
|
||||||
|
|
||||||
const me = Astro.locals.user;
|
const me = Astro.locals.user;
|
||||||
if (!me) return Astro.redirect('/signin?next=/admin/audit');
|
if (!me) return Astro.redirect('/signin?next=/admin/audit');
|
||||||
@ -18,25 +18,28 @@ const ACTION: Record<string, string> = {
|
|||||||
grant: 'اجازت دی', revoke: 'اجازت واپس لی',
|
grant: 'اجازت دی', revoke: 'اجازت واپس لی',
|
||||||
};
|
};
|
||||||
const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T', ' '));
|
const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T', ' '));
|
||||||
|
const TONE: Record<string, string> = { grant: 'ok', enable: 'ok', role: 'action', promote: 'action', 'password-reset': 'accent', disable: 'danger', delete: 'danger', revoke: 'danger' };
|
||||||
|
const detail = (e: any) => e.detail?.to ? `${ROLE[e.detail.from] ?? '—'} ← ${ROLE[e.detail.to] ?? e.detail.to}`.replace('— ← ', '')
|
||||||
|
: e.detail?.content ? [e.detail.target, e.detail.content.map((c: string) => CONTENT[c]).join('، '), e.detail.actions.map((a: string) => DO[a]).join('، ')].filter(Boolean).join(' · ') : '';
|
||||||
|
const kinds = [...new Set(log.entries.map((e: any) => e.action as string))];
|
||||||
---
|
---
|
||||||
<Base title="ایڈمن: ریکارڈ">
|
<Base title="ایڈمن: ریکارڈ">
|
||||||
<h1>ایڈمن</h1>
|
<h1>ایڈمن</h1>
|
||||||
<AdminNav current="audit" />
|
<AdminNav current="audit" />
|
||||||
<table class="admin-table">
|
{kinds.length > 1 && (
|
||||||
<thead><tr><th>وقت (UTC)</th><th>ایڈمن</th><th>عمل</th><th>صارف</th><th>تفصیل</th></tr></thead>
|
<div class="chips role-filter audit-filter" role="group" aria-label="عمل سے چھانٹیں">
|
||||||
<tbody>
|
{kinds.map((k) => <label class="chip"><input type="checkbox" value={k} checked /> {ACTION[k] ?? k}</label>)}
|
||||||
{log.entries.map((e: any) => (
|
</div>
|
||||||
<tr>
|
)}
|
||||||
<td>{when(e.at)}</td>
|
<ol class="timeline">
|
||||||
<td><bdi dir="ltr">{e.actor_email}</bdi></td>
|
{log.entries.map((e: any) => (
|
||||||
<td>{ACTION[e.action] ?? e.action}</td>
|
<li data-tone={TONE[e.action] ?? 'muted'} data-action={e.action}>
|
||||||
<td><bdi dir="ltr">{e.target_email ?? '—'}</bdi></td>
|
<header><bdi dir="ltr" class="who">{e.actor_email}</bdi><time class="muted" datetime={e.at} title={when(e.at)}>{ago(e.at)}</time></header>
|
||||||
<td>{e.detail?.to ? `${ROLE[e.detail.from] ?? '—'} ← ${ROLE[e.detail.to] ?? e.detail.to}`.replace('— ← ', '')
|
<p><strong>{ACTION[e.action] ?? e.action}</strong>{e.target_email && <>: <bdi dir="ltr" class="target">{e.target_email}</bdi></>}</p>
|
||||||
: e.detail?.content ? `${e.detail.target ?? ''} · ${e.detail.content.map((c: string) => CONTENT[c]).join('، ')} · ${e.detail.actions.map((a: string) => DO[a]).join('، ')}` : ''}</td>
|
{detail(e) && <p class="muted small-line">{detail(e)}</p>}
|
||||||
</tr>
|
</li>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</ol>
|
||||||
</table>
|
|
||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<nav class="pager">
|
<nav class="pager">
|
||||||
{page > 1 && <a class="icon-btn" href={`/admin/audit?page=${page - 1}`} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
|
{page > 1 && <a class="icon-btn" href={`/admin/audit?page=${page - 1}`} rel="prev" title="پچھلا" aria-label="پچھلا"><Icon name="prev" /></a>}
|
||||||
@ -44,4 +47,11 @@ const when = (d: string) => ud(new Date(d).toISOString().slice(0, 16).replace('T
|
|||||||
{page < pages && <a class="icon-btn" href={`/admin/audit?page=${page + 1}`} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
|
{page < pages && <a class="icon-btn" href={`/admin/audit?page=${page + 1}`} rel="next" title="اگلا" aria-label="اگلا"><Icon name="next" /></a>}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
<script>
|
||||||
|
// the action chips show and hide entries on this page
|
||||||
|
document.querySelector('.audit-filter')?.addEventListener('change', (e) => {
|
||||||
|
const on = new Set([...document.querySelectorAll<HTMLInputElement>('.audit-filter input:checked')].map((i) => i.value));
|
||||||
|
document.querySelectorAll<HTMLElement>('.timeline li').forEach((li) => (li.hidden = !on.has(li.dataset.action!)));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import ModNav from '../../components/ModNav.astro';
|
|||||||
import { asUser, COOKIE } from '../../lib/auth';
|
import { asUser, COOKIE } from '../../lib/auth';
|
||||||
import { STATUS, EVENT, when } from '../../lib/mod';
|
import { STATUS, EVENT, when } from '../../lib/mod';
|
||||||
import { ud } from '../../lib/urdu';
|
import { ud } from '../../lib/urdu';
|
||||||
|
import { ago } from '../../lib/roles';
|
||||||
|
import Icon from '../../components/Icon.astro';
|
||||||
|
|
||||||
const me = Astro.locals.user;
|
const me = Astro.locals.user;
|
||||||
if (!me) return Astro.redirect('/signin?next=/mod');
|
if (!me) return Astro.redirect('/signin?next=/mod');
|
||||||
@ -14,6 +16,9 @@ const queue = showLog ? null : (await asUser(token, '/api/mod/queue')).data;
|
|||||||
const log = showLog ? (await asUser(token, '/api/mod/log')).data : null;
|
const log = showLog ? (await asUser(token, '/api/mod/log')).data : null;
|
||||||
// what kind of change a row is (a work's text needs no label)
|
// what kind of change a row is (a work's text needs no label)
|
||||||
const KIND: Record<string, string> = { order: 'ترتیب: ', 'tags-category': 'ٹیگ: ', 'tags-work': 'ٹیگ: ', ebook: 'ای بک: ' };
|
const KIND: Record<string, string> = { order: 'ترتیب: ', 'tags-category': 'ٹیگ: ', 'tags-work': 'ٹیگ: ', ebook: 'ای بک: ' };
|
||||||
|
// each kind of change has its icon; each event its tone on the timeline
|
||||||
|
const ICON: Record<string, any> = { order: 'arrange', 'tags-category': 'tag', 'tags-work': 'tag', ebook: 'upload' };
|
||||||
|
const TONE: Record<string, string> = { published: 'ok', approved: 'action', submitted: 'accent', returned: 'danger', rejected: 'danger' };
|
||||||
const groups = queue ? [
|
const groups = queue ? [
|
||||||
['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'],
|
['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'],
|
||||||
...(me.role !== 'mod-l2' ? [['جائزے کے منتظر', queue.review, 'جائزے کے لیے کچھ نہیں۔']] : []),
|
...(me.role !== 'mod-l2' ? [['جائزے کے منتظر', queue.review, 'جائزے کے لیے کچھ نہیں۔']] : []),
|
||||||
@ -24,36 +29,39 @@ const groups = queue ? [
|
|||||||
<h1>موڈریشن</h1>
|
<h1>موڈریشن</h1>
|
||||||
<ModNav current={showLog ? 'log' : 'queue'} />
|
<ModNav current={showLog ? 'log' : 'queue'} />
|
||||||
{Astro.url.searchParams.has('unchanged') && <p class="form-done" role="status">متن میں کوئی تبدیلی نہیں تھی، اس لیے مسودہ نہیں رکھا گیا۔</p>}
|
{Astro.url.searchParams.has('unchanged') && <p class="form-done" role="status">متن میں کوئی تبدیلی نہیں تھی، اس لیے مسودہ نہیں رکھا گیا۔</p>}
|
||||||
{groups.map(([title, items, empty]) => (
|
{queue && (
|
||||||
<section class="lib-section">
|
<nav class="stat-tiles" aria-label="خلاصہ">
|
||||||
|
{groups.map(([title, items], i) => <a class="stat" href={`#q${i}`}><strong>{ud(items.length)}</strong><span>{title}</span></a>)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
{groups.map(([title, items, empty], i) => (
|
||||||
|
<section class="queue" id={`q${i}`}>
|
||||||
<h2>{title} <small class="muted">{ud(items.length)}</small></h2>
|
<h2>{title} <small class="muted">{ud(items.length)}</small></h2>
|
||||||
{items.length === 0 ? <p class="muted center">{empty}</p> : (
|
{items.length === 0 ? <p class="empty"><span aria-hidden="true">✤</span>{empty}</p> : (
|
||||||
<ul class="lib-list">
|
<ul class="queue-list">
|
||||||
{items.map((r) => (
|
{items.map((r) => (
|
||||||
<li>
|
<li><a class="queue-card" href={`/mod/rev/${r.id}`}>
|
||||||
<a class="lib-title" href={`/mod/rev/${r.id}`}>{KIND[r.entity] ?? ''}{r.title}</a>
|
<span class="kind" aria-hidden="true"><Icon name={ICON[r.entity] ?? 'edit'} size={18} /></span>
|
||||||
|
<span class="what"><strong>{KIND[r.entity] ?? ''}{r.title}</strong>
|
||||||
|
<small class="muted">{r.summary || 'بغیر خلاصہ'} · <bdi dir="ltr">{r.author_email}</bdi></small></span>
|
||||||
<span class={`status s-${r.status}`}>{STATUS[r.status]}</span>
|
<span class={`status s-${r.status}`}>{STATUS[r.status]}</span>
|
||||||
<p class="muted small-line">{r.summary || 'بغیر خلاصہ'} · <bdi dir="ltr">{r.author_email}</bdi> · <bdi dir="ltr">{when(r.updated_at)}</bdi></p>
|
<span class="seen muted" title={when(r.updated_at)}>{ago(r.updated_at)}</span>
|
||||||
</li>
|
</a></li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
{log && (
|
{log && (
|
||||||
<table class="admin-table">
|
<ol class="timeline">
|
||||||
<thead><tr><th>وقت (UTC)</th><th>کس نے</th><th>کیا</th><th>کلام</th><th>تبصرہ</th></tr></thead>
|
{log.entries.map((e: any) => (
|
||||||
<tbody>
|
<li data-tone={TONE[e.action] ?? 'muted'}>
|
||||||
{log.entries.map((e: any) => (
|
<header><bdi dir="ltr" class="who">{e.actor_email}</bdi><time class="muted" datetime={e.at} title={when(e.at)}>{ago(e.at)}</time></header>
|
||||||
<tr>
|
<p><strong>{EVENT[e.action] ?? e.action}{e.version && e.action === 'published' ? ` (ورژن ${ud(e.version)})` : ''}</strong>:
|
||||||
<td><bdi dir="ltr">{when(e.at)}</bdi></td>
|
<a href={`/mod/rev/${e.revision}`}>{KIND[e.entity] ?? ''}{e.title}</a></p>
|
||||||
<td><bdi dir="ltr">{e.actor_email}</bdi></td>
|
{e.comment && <blockquote>{e.comment}</blockquote>}
|
||||||
<td>{EVENT[e.action] ?? e.action}{e.version && e.action === 'published' ? ` (ورژن ${ud(e.version)})` : ''}</td>
|
</li>
|
||||||
<td><a href={`/mod/rev/${e.revision}`}>{KIND[e.entity] ?? ''}{e.title}</a></td>
|
))}
|
||||||
<td>{e.comment}</td>
|
</ol>
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@ -741,3 +741,38 @@ p, li { text-wrap: pretty; }
|
|||||||
.couplet:hover .sh, .poem .para:hover .sh, .poem .single:hover .sh, .sh:focus-visible { opacity: 1; }
|
.couplet:hover .sh, .poem .para:hover .sh, .poem .single:hover .sh, .sh:focus-visible { opacity: 1; }
|
||||||
@media (hover: none) { .sh { opacity: .55; } }
|
@media (hover: none) { .sh { opacity: .55; } }
|
||||||
@media (max-width: 700px) { .sh { inset-inline-end: -4px; top: 52px; } }
|
@media (max-width: 700px) { .sh { inset-inline-end: -4px; top: 52px; } }
|
||||||
|
|
||||||
|
/* moderation dashboard: count tiles that jump to their queue; queue items as cards */
|
||||||
|
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; max-width: 760px; margin: 0 auto 18px; }
|
||||||
|
.stat { display: flex; flex-direction: column; align-items: center; padding: 12px 10px 8px; border: 1.5px solid var(--border); border-radius: var(--r-lg);
|
||||||
|
background: var(--surface-raised); color: var(--text); transition: transform .2s, border-color .2s; }
|
||||||
|
.stat:hover { transform: translateY(-2px); border-color: var(--active); color: var(--text); }
|
||||||
|
.stat strong { font: 700 2.2rem/1.4 var(--font-ui); color: var(--action); }
|
||||||
|
.stat span { font-size: var(--fs-sm); color: var(--text-muted); }
|
||||||
|
.queue { max-width: 760px; margin: 0 auto 22px; scroll-margin-top: 16px; }
|
||||||
|
.queue h2 { text-align: start; font-size: var(--fs-lg); }
|
||||||
|
.queue h2::after { display: none; }
|
||||||
|
.queue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.queue-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--r-md);
|
||||||
|
background: var(--surface-raised); color: var(--text); transition: transform .2s, border-color .2s; }
|
||||||
|
.queue-card:hover { transform: translateY(-1px); border-color: var(--active); color: var(--text); }
|
||||||
|
.queue-card .kind { flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.queue-card .what { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.6; }
|
||||||
|
.queue-card .what small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-xs); }
|
||||||
|
@media (max-width: 560px) { .queue-card { flex-wrap: wrap; } .queue-card .status { margin-inline-start: 46px; } }
|
||||||
|
/* an empty queue or list: a quiet line with the ✤ */
|
||||||
|
.empty { text-align: center; color: var(--text-muted); padding: 14px; border: 1px dashed var(--line); border-radius: var(--r-md); }
|
||||||
|
.empty span { display: block; color: var(--brand); opacity: .6; font-size: 1.3rem; }
|
||||||
|
/* timelines (moderation activity, admin record): a dot per entry, coloured by what happened */
|
||||||
|
.timeline { list-style: none; max-width: 760px; margin: 8px auto; padding: 0; padding-inline-start: 22px; border-inline-start: 2px solid var(--line); }
|
||||||
|
.timeline li { position: relative; margin: 0 0 12px; padding: 8px 14px; border-radius: var(--r-md); background: var(--surface-raised); border: 1px solid var(--line); }
|
||||||
|
.timeline li::before { content: ''; position: absolute; inset-inline-start: -30px; top: 16px; width: 12px; height: 12px; border-radius: 50%;
|
||||||
|
background: var(--tone); box-shadow: 0 0 0 3px var(--surface-page); }
|
||||||
|
.timeline li p { margin: 0; line-height: 1.8; }
|
||||||
|
.timeline .who { font: var(--fs-sm) var(--font-latin); color: var(--text-muted); }
|
||||||
|
.timeline header { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
|
||||||
|
.timeline time { font-size: var(--fs-xs); white-space: nowrap; }
|
||||||
|
.timeline .target { font-family: var(--font-latin); }
|
||||||
|
.timeline blockquote { margin: 4px 0; padding-inline-start: 10px; border-inline-start: 2px solid var(--tone); color: var(--text-muted); font-size: var(--fs-sm); }
|
||||||
|
[data-tone] { --tone: var(--text-muted); }
|
||||||
|
[data-tone='ok'] { --tone: var(--ok); } [data-tone='action'] { --tone: var(--action); } [data-tone='accent'] { --tone: var(--accent); } [data-tone='danger'] { --tone: var(--danger); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user