Content moderation queue
One report, one decision, next report. A moderation queue built as a
single md-card that reloads with the next item after
every verdict: the reporter’s md-avatar and metadata,
the flagged content, report reasons as md-chips, the
classifier’s confidence as an md-meter whose color role
tracks severity, and a verdict row where
md-segmented-button-set picks the outcome.
Choosing Remove swaps the plain submit button for an
md-split-button whose menu carries the harsher
variants — every action is confirmed by an
md-snackbar, and an md-badge
counts down the queue.
Live preview — pick a verdict to advance through the four pending reports
Moderation queue
Pending
Priya Desai
Reported @glowdeals · 12 min ago · Report #4821
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<div style="display: flex; align-items: center; justify-content: space-between;">
<span>Moderation queue</span>
<span style="position: relative;">Pending<md-badge value="4"></md-badge></span>
</div>
<md-card variant="outlined" full-width>
<div style="display: flex; align-items: center; gap: 12px;">
<md-avatar initials="PD"></md-avatar>
<div>
<span>Priya Desai</span>
<span>Reported @glowdeals · 12 min ago · Report #4821</span>
</div>
</div>
<blockquote>"FINAL HOURS!! Send a $50 gift card code to claim your prize before midnight."</blockquote>
<div role="group" aria-label="Report reasons">
<md-chip variant="assist" label="Scam or fraud" color="error"></md-chip>
<md-chip variant="assist" label="Spam" color="warning"></md-chip>
</div>
<md-meter label="Model confidence — high" value="94" color="error" show-label show-value></md-meter>
<md-divider></md-divider>
<md-segmented-button-set aria-label="Verdict">
<md-segmented-button value="approve" label="Approve"></md-segmented-button>
<md-segmented-button value="escalate" label="Escalate"></md-segmented-button>
<md-segmented-button value="remove" label="Remove"></md-segmented-button>
</md-segmented-button-set>
<md-button variant="filled" disabled>Submit verdict</md-button>
<md-split-button id="remove-split" variant="filled" label="Remove post"
menu-label="More removal options" controls="remove-menu"></md-split-button>
<md-menu id="remove-menu" anchor="remove-split" placement="bottom-end">
<md-menu-item headline="Remove and warn author"></md-menu-item>
<md-menu-item headline="Remove and ban author"></md-menu-item>
</md-menu>
</md-card>
<md-snackbar></md-snackbar>
<script type="module">
const card = document.querySelector('[data-report-card]');
const avatar = document.querySelector('[data-reporter-avatar]');
const reporterEl = document.querySelector('[data-reporter]');
const metaEl = document.querySelector('[data-meta]');
const excerptEl = document.querySelector('[data-excerpt]');
const reasonsEl = document.querySelector('[data-reasons]');
const meter = document.querySelector('[data-confidence]');
const verdictSet = document.querySelector('[data-verdict]');
const submitBtn = document.querySelector('[data-submit]');
const split = document.querySelector('[data-split]');
const menu = document.getElementById('mq-remove-menu');
const toast = document.querySelector('[data-toast]');
const badge = document.querySelector('[data-queue-count]');
const emptyEl = document.querySelector('[data-empty]');
const reports = [
{ reporter: 'Priya Desai', handle: '@glowdeals', age: '12 min', id: 4821,
excerpt: 'FINAL HOURS!! Send a $50 gift card code to claim your prize before midnight.',
reasons: [['Scam or fraud', 'error'], ['Spam', 'warning']], confidence: 94 },
{ reporter: 'Marcus Webb', handle: '@kt_owens', age: '28 min', id: 4820,
excerpt: 'Everyone from that neighborhood is a criminal, simple as that.',
reasons: [['Hate speech', 'error']], confidence: 71 },
{ reporter: 'Sofia Marek', handle: '@dailyfit_max', age: '1 hr', id: 4817,
excerpt: 'This supplement reverses diabetes in two weeks. Doctors will not tell you this.',
reasons: [['Medical misinformation', 'warning'], ['Spam', 'warning']], confidence: 88 },
{ reporter: 'Devon Alvarez', handle: '@ravi.codes', age: '2 hr', id: 4809,
excerpt: 'Nice tutorial. I posted the full source code at the link in my bio.',
reasons: [['Self-promotion', 'info']], confidence: 23 },
];
let index = 0;
function initialsOf(name) {
const parts = name.trim().split(/\s+/);
const first = parts[0].charAt(0);
const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
function render() {
const r = reports[index];
badge.value = String(reports.length - index);
avatar.initials = initialsOf(r.reporter);
reporterEl.textContent = r.reporter;
metaEl.textContent = 'Reported ' + r.handle + ' \u00b7 ' + r.age + ' ago \u00b7 Report #' + r.id;
excerptEl.textContent = '\u201c' + r.excerpt + '\u201d';
reasonsEl.textContent = '';
r.reasons.forEach((pair) => {
const chip = document.createElement('md-chip');
chip.variant = 'assist';
chip.label = pair[0];
chip.color = pair[1];
reasonsEl.appendChild(chip);
});
meter.value = r.confidence;
const band = r.confidence >= 85 ? 'error' : r.confidence >= 50 ? 'warning' : 'success';
meter.color = band;
meter.label = 'Model confidence \u2014 ' +
(band === 'error' ? 'high' : band === 'warning' ? 'medium' : 'low');
// Reset the verdict controls for the fresh item.
verdictSet.querySelectorAll('md-segmented-button').forEach((seg) => { seg.selected = false; });
submitBtn.disabled = true;
submitBtn.style.display = '';
split.style.display = 'none';
}
function advance(message) {
index += 1;
toast.message = message;
toast.show();
if (index >= reports.length) {
badge.value = '0';
card.style.display = 'none';
emptyEl.style.display = '';
} else {
render();
}
}
// The set owns selection; mdChange carries the selected values as an array.
verdictSet.addEventListener('mdChange', (e) => {
const verdict = e.detail[0] || '';
const isRemove = verdict === 'remove';
submitBtn.style.display = isRemove ? 'none' : '';
split.style.display = isRemove ? '' : 'none';
submitBtn.disabled = verdict === '';
});
submitBtn.addEventListener('mdClick', () => {
const r = reports[index];
const selected = verdictSet.querySelector('md-segmented-button[selected]');
if (!selected) return;
if (selected.value === 'approve') {
advance('Report #' + r.id + ' dismissed \u2014 post stays up');
} else {
advance('Report #' + r.id + ' escalated to a senior reviewer');
}
});
// Split button: leading segment is the default removal; the menu holds variants.
split.addEventListener('mdLeadingClick', () => {
const r = reports[index];
advance('Post removed \u2014 report #' + r.id + ' closed');
});
split.addEventListener('mdTrailingClick', (e) => {
if (e.detail.checked) menu.show();
else menu.close();
});
// Keep the chevron truthful when the menu closes by outside click or Escape.
menu.addEventListener('mdClose', () => { split.trailingChecked = false; });
document.querySelector('[data-remove-warn]').addEventListener('mdClick', () => {
const r = reports[index];
advance('Post removed, warning sent \u2014 report #' + r.id + ' closed');
});
document.querySelector('[data-remove-ban]').addEventListener('mdClick', () => {
const r = reports[index];
advance('Post removed, author banned \u2014 report #' + r.id + ' closed');
});
render();
</script>import { useState } from 'react';
import {
MdAvatar, MdBadge, MdButton, MdCard, MdChip, MdDivider, MdMenu, MdMenuItem,
MdMeter, MdSegmentedButton, MdSegmentedButtonSet, MdSnackbar, MdSplitButton,
} from '@awc-ui/react';
const REPORTS = [
{ reporter: 'Priya Desai', handle: '@glowdeals', age: '12 min', id: 4821,
excerpt: 'FINAL HOURS!! Send a gift card code to claim your prize before midnight.',
reasons: [['Scam or fraud', 'error'], ['Spam', 'warning']], confidence: 94 },
{ reporter: 'Marcus Webb', handle: '@kt_owens', age: '28 min', id: 4820,
excerpt: 'Everyone from that neighborhood is a criminal, simple as that.',
reasons: [['Hate speech', 'error']], confidence: 71 },
{ reporter: 'Devon Alvarez', handle: '@ravi.codes', age: '2 hr', id: 4809,
excerpt: 'Nice tutorial. I posted the full source code at the link in my bio.',
reasons: [['Self-promotion', 'info']], confidence: 23 },
];
const initialsOf = (name: string) => {
const parts = name.trim().split(' ');
return (parts[0].charAt(0) + (parts.length > 1 ? parts[parts.length - 1].charAt(0) : '')).toUpperCase();
};
export default function ModerationQueue() {
const [index, setIndex] = useState(0);
const [verdict, setVerdict] = useState('');
const [menuOpen, setMenuOpen] = useState(false);
const [toast, setToast] = useState({ open: false, message: '' });
const report = REPORTS[index];
const done = index >= REPORTS.length;
const band = done ? 'success' : report.confidence >= 85 ? 'error' : report.confidence >= 50 ? 'warning' : 'success';
const bandWord = band === 'error' ? 'high' : band === 'warning' ? 'medium' : 'low';
const advance = (message: string) => {
setIndex(index + 1);
setVerdict('');
setMenuOpen(false);
setToast({ open: true, message });
};
const submit = () => {
if (verdict === 'approve') advance('Report #' + report.id + ' dismissed — post stays up');
else advance('Report #' + report.id + ' escalated to a senior reviewer');
};
return (
<div style={{ display: 'grid', gap: 16, maxWidth: 640, marginInline: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Moderation queue</span>
<span style={{ position: 'relative', paddingInlineEnd: 10 }}>
Pending<MdBadge value={String(REPORTS.length - index)} />
</span>
</div>
{!done ? (
<MdCard variant="outlined" fullWidth>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<MdAvatar initials={initialsOf(report.reporter)} />
<div style={{ display: 'grid' }}>
<span>{report.reporter}</span>
<span>Reported {report.handle} · {report.age} ago · Report #{report.id}</span>
</div>
</div>
<blockquote style={{ margin: 0, padding: '12px 16px', borderRadius: 8, background: 'var(--md-sys-color-surface-container)' }}>
“{report.excerpt}”
</blockquote>
<div role="group" aria-label="Report reasons" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{report.reasons.map(([label, color]) => (
<MdChip key={label} variant="assist" label={label} color={color} />
))}
</div>
<MdMeter value={report.confidence} color={band} label={'Model confidence — ' + bandWord} showLabel showValue />
<MdDivider />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', justifyContent: 'space-between' }}>
<MdSegmentedButtonSet aria-label="Verdict" onMdChange={(e) => setVerdict(e.detail[0] ?? '')}>
<MdSegmentedButton value="approve" label="Approve" selected={verdict === 'approve'} />
<MdSegmentedButton value="escalate" label="Escalate" selected={verdict === 'escalate'} />
<MdSegmentedButton value="remove" label="Remove" selected={verdict === 'remove'} />
</MdSegmentedButtonSet>
{verdict !== 'remove' ? (
<MdButton variant="filled" disabled={!verdict} onMdClick={submit}>Submit verdict</MdButton>
) : (
<MdSplitButton id="remove-split" variant="filled" label="Remove post"
menuLabel="More removal options" controls="remove-menu"
trailingChecked={menuOpen}
onMdLeadingClick={() => advance('Post removed — report #' + report.id + ' closed')}
onMdTrailingClick={(e) => setMenuOpen(e.detail.checked)} />
)}
<MdMenu id="remove-menu" anchor="remove-split" placement="bottom-end"
open={menuOpen} onMdClose={() => setMenuOpen(false)}>
<MdMenuItem headline="Remove and warn author"
onMdClick={() => advance('Post removed, warning sent — report #' + report.id + ' closed')} />
<MdMenuItem headline="Remove and ban author"
onMdClick={() => advance('Post removed, author banned — report #' + report.id + ' closed')} />
</MdMenu>
</div>
</MdCard>
) : (
<div style={{ padding: '32px 16px', textAlign: 'center' }}>
<div>Queue clear</div>
<div>All pending reports have been reviewed.</div>
</div>
)}
<MdSnackbar open={toast.open} message={toast.message}
onMdClose={() => setToast({ open: false, message: toast.message })} />
</div>
);
}import { Component } from '@angular/core';
import {
MdAvatar, MdBadge, MdButton, MdCard, MdChip, MdDivider, MdMenu, MdMenuItem,
MdMeter, MdSegmentedButton, MdSegmentedButtonSet, MdSnackbar, MdSplitButton,
} from '@awc-ui/angular';
@Component({
selector: 'app-moderation-queue',
standalone: true,
imports: [
MdAvatar, MdBadge, MdButton, MdCard, MdChip, MdDivider, MdMenu, MdMenuItem,
MdMeter, MdSegmentedButton, MdSegmentedButtonSet, MdSnackbar, MdSplitButton,
],
template:
'<div class="queue">' +
'<div class="head"><span>Moderation queue</span>' +
'<span class="pending">Pending<md-badge [value]="remaining"></md-badge></span></div>' +
'@if (!done) {' +
'<md-card variant="outlined" full-width>' +
'<div class="reporter"><md-avatar [initials]="reporterInitials"></md-avatar>' +
'<div><span>{{ report.reporter }}</span>' +
'<span>Reported {{ report.handle }} · {{ report.age }} ago · Report #{{ report.id }}</span></div></div>' +
'<blockquote>“{{ report.excerpt }}”</blockquote>' +
'<div role="group" aria-label="Report reasons" class="reasons">' +
'@for (pair of report.reasons; track pair[0]) {' +
'<md-chip variant="assist" [label]="pair[0]" [color]="pair[1]"></md-chip>' +
'}</div>' +
'<md-meter [value]="report.confidence" [color]="band" [label]="meterLabel" show-label show-value></md-meter>' +
'<md-divider></md-divider>' +
'<div class="actions">' +
'<md-segmented-button-set aria-label="Verdict" (mdChange)="onVerdict($event)">' +
'<md-segmented-button value="approve" label="Approve" [selected]="isApprove"></md-segmented-button>' +
'<md-segmented-button value="escalate" label="Escalate" [selected]="isEscalate"></md-segmented-button>' +
'<md-segmented-button value="remove" label="Remove" [selected]="isRemove"></md-segmented-button>' +
'</md-segmented-button-set>' +
'@if (!isRemove) {' +
'<md-button variant="filled" [disabled]="!verdict" (mdClick)="submit()">Submit verdict</md-button>' +
'} @else {' +
'<md-split-button id="remove-split" variant="filled" label="Remove post" ' +
'menu-label="More removal options" controls="remove-menu" [trailingChecked]="menuOpen" ' +
'(mdLeadingClick)="removePost()" (mdTrailingClick)="onChevron($event)"></md-split-button>' +
'}' +
'<md-menu id="remove-menu" anchor="remove-split" placement="bottom-end" [open]="menuOpen" (mdClose)="menuOpen = false">' +
'<md-menu-item headline="Remove and warn author" (mdClick)="removeAndWarn()"></md-menu-item>' +
'<md-menu-item headline="Remove and ban author" (mdClick)="removeAndBan()"></md-menu-item>' +
'</md-menu></div></md-card>' +
'} @else {' +
'<div class="empty"><div>Queue clear</div><div>All pending reports have been reviewed.</div></div>' +
'}' +
'<md-snackbar [open]="toastOpen" [message]="toastMessage" (mdClose)="toastOpen = false"></md-snackbar>' +
'</div>',
styles: [
'.queue { display: grid; gap: 16px; max-width: 640px; margin-inline: auto; }' +
' .head { display: flex; align-items: center; justify-content: space-between; }' +
' .pending { position: relative; padding-inline-end: 10px; }' +
' .reporter { display: flex; align-items: center; gap: 12px; }' +
' .reasons { display: flex; gap: 8px; flex-wrap: wrap; }' +
' .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }',
],
})
export class ModerationQueueComponent {
readonly reports = [
{ reporter: 'Priya Desai', handle: '@glowdeals', age: '12 min', id: 4821,
excerpt: 'FINAL HOURS!! Send a gift card code to claim your prize before midnight.',
reasons: [['Scam or fraud', 'error'], ['Spam', 'warning']], confidence: 94 },
{ reporter: 'Marcus Webb', handle: '@kt_owens', age: '28 min', id: 4820,
excerpt: 'Everyone from that neighborhood is a criminal, simple as that.',
reasons: [['Hate speech', 'error']], confidence: 71 },
{ reporter: 'Devon Alvarez', handle: '@ravi.codes', age: '2 hr', id: 4809,
excerpt: 'Nice tutorial. I posted the full source code at the link in my bio.',
reasons: [['Self-promotion', 'info']], confidence: 23 },
];
index = 0;
verdict = '';
menuOpen = false;
toastOpen = false;
toastMessage = '';
get report() { return this.reports[this.index]; }
get done() { return this.index >= this.reports.length; }
get remaining() { return String(this.reports.length - this.index); }
get isApprove() { return this.verdict === 'approve'; }
get isEscalate() { return this.verdict === 'escalate'; }
get isRemove() { return this.verdict === 'remove'; }
get band() {
const c = this.done ? 0 : this.report.confidence;
return c >= 85 ? 'error' : c >= 50 ? 'warning' : 'success';
}
get meterLabel() {
return 'Model confidence — ' + (this.band === 'error' ? 'high' : this.band === 'warning' ? 'medium' : 'low');
}
get reporterInitials() {
const parts = this.report.reporter.trim().split(' ');
return (parts[0].charAt(0) + (parts.length > 1 ? parts[parts.length - 1].charAt(0) : '')).toUpperCase();
}
onVerdict(e: CustomEvent<string[]>) { this.verdict = e.detail[0] || ''; }
onChevron(e: CustomEvent<{ checked: boolean }>) { this.menuOpen = e.detail.checked; }
submit() {
if (this.isApprove) this.advance('Report #' + this.report.id + ' dismissed — post stays up');
else this.advance('Report #' + this.report.id + ' escalated to a senior reviewer');
}
removePost() { this.advance('Post removed — report #' + this.report.id + ' closed'); }
removeAndWarn() { this.advance('Post removed, warning sent — report #' + this.report.id + ' closed'); }
removeAndBan() { this.advance('Post removed, author banned — report #' + this.report.id + ' closed'); }
private advance(message: string) {
this.index += 1;
this.verdict = '';
this.menuOpen = false;
this.toastMessage = message;
this.toastOpen = true;
}
}<script setup lang="ts">
import { computed, ref } from 'vue';
import {
MdAvatar, MdBadge, MdButton, MdCard, MdChip, MdDivider, MdMenu, MdMenuItem,
MdMeter, MdSegmentedButton, MdSegmentedButtonSet, MdSnackbar, MdSplitButton,
} from '@awc-ui/vue';
const reports = [
{ reporter: 'Priya Desai', handle: '@glowdeals', age: '12 min', id: 4821,
excerpt: 'FINAL HOURS!! Send a gift card code to claim your prize before midnight.',
reasons: [['Scam or fraud', 'error'], ['Spam', 'warning']], confidence: 94 },
{ reporter: 'Marcus Webb', handle: '@kt_owens', age: '28 min', id: 4820,
excerpt: 'Everyone from that neighborhood is a criminal, simple as that.',
reasons: [['Hate speech', 'error']], confidence: 71 },
{ reporter: 'Devon Alvarez', handle: '@ravi.codes', age: '2 hr', id: 4809,
excerpt: 'Nice tutorial. I posted the full source code at the link in my bio.',
reasons: [['Self-promotion', 'info']], confidence: 23 },
];
const index = ref(0);
const verdict = ref('');
const menuOpen = ref(false);
const toastOpen = ref(false);
const toastMessage = ref('');
const report = computed(() => reports[index.value]);
const done = computed(() => index.value >= reports.length);
const remaining = computed(() => String(reports.length - index.value));
const band = computed(() => {
const c = report.value ? report.value.confidence : 0;
return c >= 85 ? 'error' : c >= 50 ? 'warning' : 'success';
});
const meterLabel = computed(() =>
'Model confidence — ' + (band.value === 'error' ? 'high' : band.value === 'warning' ? 'medium' : 'low'));
function initialsOf(name: string) {
const parts = name.trim().split(' ');
return (parts[0].charAt(0) + (parts.length > 1 ? parts[parts.length - 1].charAt(0) : '')).toUpperCase();
}
function onVerdict(e: CustomEvent<string[]>) {
verdict.value = e.detail[0] || '';
}
function advance(message: string) {
index.value += 1;
verdict.value = '';
menuOpen.value = false;
toastMessage.value = message;
toastOpen.value = true;
}
function submit() {
if (verdict.value === 'approve') advance('Report #' + report.value.id + ' dismissed — post stays up');
else advance('Report #' + report.value.id + ' escalated to a senior reviewer');
}
</script>
<template>
<div class="queue">
<div class="head">
<span>Moderation queue</span>
<span class="pending">Pending<MdBadge :value="remaining" /></span>
</div>
<MdCard v-if="!done" variant="outlined" full-width>
<div class="reporter">
<MdAvatar :initials="initialsOf(report.reporter)" />
<div>
<span>{{ report.reporter }}</span>
<span>Reported {{ report.handle }} · {{ report.age }} ago · Report #{{ report.id }}</span>
</div>
</div>
<blockquote>“{{ report.excerpt }}”</blockquote>
<div role="group" aria-label="Report reasons" class="reasons">
<MdChip v-for="pair in report.reasons" :key="pair[0]" variant="assist" :label="pair[0]" :color="pair[1]" />
</div>
<MdMeter :value="report.confidence" :color="band" :label="meterLabel" show-label show-value />
<MdDivider />
<div class="actions">
<MdSegmentedButtonSet aria-label="Verdict" @md-change="onVerdict">
<MdSegmentedButton value="approve" label="Approve" :selected="verdict === 'approve'" />
<MdSegmentedButton value="escalate" label="Escalate" :selected="verdict === 'escalate'" />
<MdSegmentedButton value="remove" label="Remove" :selected="verdict === 'remove'" />
</MdSegmentedButtonSet>
<MdButton v-if="verdict !== 'remove'" variant="filled" :disabled="!verdict" @md-click="submit">
Submit verdict
</MdButton>
<MdSplitButton v-else id="remove-split" variant="filled" label="Remove post"
menu-label="More removal options" controls="remove-menu" :trailing-checked="menuOpen"
@md-leading-click="advance('Post removed — report #' + report.id + ' closed')"
@md-trailing-click="menuOpen = $event.detail.checked" />
<MdMenu id="remove-menu" anchor="remove-split" placement="bottom-end"
:open="menuOpen" @md-close="menuOpen = false">
<MdMenuItem headline="Remove and warn author"
@md-click="advance('Post removed, warning sent — report #' + report.id + ' closed')" />
<MdMenuItem headline="Remove and ban author"
@md-click="advance('Post removed, author banned — report #' + report.id + ' closed')" />
</MdMenu>
</div>
</MdCard>
<div v-else class="empty">
<div>Queue clear</div>
<div>All pending reports have been reviewed.</div>
</div>
<MdSnackbar :open="toastOpen" :message="toastMessage" @md-close="toastOpen = false" />
</div>
</template>
<style scoped>
.queue { display: grid; gap: 16px; max-width: 640px; margin-inline: auto; }
.head { display: flex; align-items: center; justify-content: space-between; }
.pending { position: relative; padding-inline-end: 10px; }
.reporter { display: flex; align-items: center; gap: 12px; }
.reasons { display: flex; gap: 8px; flex-wrap: wrap; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
</style><script>
// Registers every md-* element once on the client.
// In SvelteKit, run this behind an if (browser) guard from '$app/environment'.
import { defineCustomElements } from '@awc-ui/svelte';
if (typeof window !== 'undefined') defineCustomElements(window);
const reports = [
{ reporter: 'Priya Desai', handle: '@glowdeals', age: '12 min', id: 4821,
excerpt: 'FINAL HOURS!! Send a gift card code to claim your prize before midnight.',
reasons: [['Scam or fraud', 'error'], ['Spam', 'warning']], confidence: 94 },
{ reporter: 'Marcus Webb', handle: '@kt_owens', age: '28 min', id: 4820,
excerpt: 'Everyone from that neighborhood is a criminal, simple as that.',
reasons: [['Hate speech', 'error']], confidence: 71 },
{ reporter: 'Devon Alvarez', handle: '@ravi.codes', age: '2 hr', id: 4809,
excerpt: 'Nice tutorial. I posted the full source code at the link in my bio.',
reasons: [['Self-promotion', 'info']], confidence: 23 },
];
let index = 0;
let verdict = '';
let menuOpen = false;
let toastOpen = false;
let toastMessage = '';
$: report = reports[index];
$: done = index >= reports.length;
$: remaining = String(reports.length - index);
$: band = !report ? 'success' : report.confidence >= 85 ? 'error' : report.confidence >= 50 ? 'warning' : 'success';
$: meterLabel = 'Model confidence — ' + (band === 'error' ? 'high' : band === 'warning' ? 'medium' : 'low');
function initialsOf(name) {
const parts = name.trim().split(' ');
return (parts[0].charAt(0) + (parts.length > 1 ? parts[parts.length - 1].charAt(0) : '')).toUpperCase();
}
function onVerdict(e) {
verdict = e.detail[0] || '';
}
function advance(message) {
index += 1;
verdict = '';
menuOpen = false;
toastMessage = message;
toastOpen = true;
}
function submit() {
if (verdict === 'approve') advance('Report #' + report.id + ' dismissed — post stays up');
else advance('Report #' + report.id + ' escalated to a senior reviewer');
}
</script>
<div class="queue">
<div class="head">
<span>Moderation queue</span>
<span class="pending">Pending<md-badge value={remaining}></md-badge></span>
</div>
{#if !done}
<md-card variant="outlined" full-width>
<div class="reporter">
<md-avatar initials={initialsOf(report.reporter)}></md-avatar>
<div>
<span>{report.reporter}</span>
<span>Reported {report.handle} · {report.age} ago · Report #{report.id}</span>
</div>
</div>
<blockquote>“{report.excerpt}”</blockquote>
<div role="group" aria-label="Report reasons" class="reasons">
{#each report.reasons as pair (pair[0])}
<md-chip variant="assist" label={pair[0]} color={pair[1]}></md-chip>
{/each}
</div>
<md-meter value={report.confidence} color={band} label={meterLabel} show-label show-value></md-meter>
<md-divider></md-divider>
<div class="actions">
<md-segmented-button-set aria-label="Verdict" on:mdChange={onVerdict}>
<md-segmented-button value="approve" label="Approve" selected={verdict === 'approve'}></md-segmented-button>
<md-segmented-button value="escalate" label="Escalate" selected={verdict === 'escalate'}></md-segmented-button>
<md-segmented-button value="remove" label="Remove" selected={verdict === 'remove'}></md-segmented-button>
</md-segmented-button-set>
{#if verdict !== 'remove'}
<md-button variant="filled" disabled={!verdict} on:mdClick={submit}>Submit verdict</md-button>
{:else}
<md-split-button id="remove-split" variant="filled" label="Remove post"
menu-label="More removal options" controls="remove-menu" trailing-checked={menuOpen}
on:mdLeadingClick={() => advance('Post removed — report #' + report.id + ' closed')}
on:mdTrailingClick={(e) => (menuOpen = e.detail.checked)}></md-split-button>
{/if}
<md-menu id="remove-menu" anchor="remove-split" placement="bottom-end"
open={menuOpen} on:mdClose={() => (menuOpen = false)}>
<md-menu-item headline="Remove and warn author"
on:mdClick={() => advance('Post removed, warning sent — report #' + report.id + ' closed')}></md-menu-item>
<md-menu-item headline="Remove and ban author"
on:mdClick={() => advance('Post removed, author banned — report #' + report.id + ' closed')}></md-menu-item>
</md-menu>
</div>
</md-card>
{:else}
<div class="empty">
<div>Queue clear</div>
<div>All pending reports have been reviewed.</div>
</div>
{/if}
<md-snackbar open={toastOpen} message={toastMessage} on:mdClose={() => (toastOpen = false)}></md-snackbar>
</div>
<style>
.queue { display: grid; gap: 16px; max-width: 640px; margin-inline: auto; }
.head { display: flex; align-items: center; justify-content: space-between; }
.pending { position: relative; padding-inline-end: 10px; }
.reporter { display: flex; align-items: center; gap: 12px; }
.reasons { display: flex; gap: 8px; flex-wrap: wrap; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
</style>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-card | The report container — one subject (the flagged post) per card, per M3. It stays a plain, non-interactive container because its children are the controls. |
md-avatar | Reporter identity. initials only, with label/name left empty — the name is adjacent text, so the avatar stays decorative and isn’t announced twice. |
md-chip | Report reasons as an assist-chip set inside a role="group". color takes a theme role name (error, warning, info), so severity comes from the theme, not hard-coded hex. |
md-meter | The classifier’s confidence — a state, not an activity, which is exactly the meter/progress split. The script swaps color at the 50/85 thresholds and writes the band into label so color is never the only signal. |
md-segmented-button-set | The verdict: three exclusive options, all visible. The set owns selection and reports it through mdChange; the initial state has nothing selected, so the submit button starts disabled. |
md-split-button | The removal path: the leading segment is the default (“Remove post”), the trailing chevron opens an md-menu of genuine variations (warn, ban). controls wires aria-controls to the menu’s id. |
md-snackbar | Per-verdict confirmation. One element, re-show()n with a new message — never two at once. |
md-badge | The pending count, anchored to a position: relative wrapper and counting down as verdicts land. |
The details that make it production-grade
Section titled “The details that make it production-grade”- The segmented set can’t be un-chosen by accident. Single-select
mdChangealways reports a selection — clicking the active segment re-selects it rather than clearing it — so once a reviewer picks a verdict, the action buttons never flicker back to disabled. Arrow keys move and select through the three verdicts per the radiogroup pattern. - The destructive path costs one more decision. “Remove” doesn’t fire on segment click; it reveals the split button, whose leading action is the mildest removal. The harsher variants (warn, ban) live behind the chevron — the M3 split-button contract of a default plus variations.
- The chevron never lies.
mdTrailingClick.detail.checkeddrivesmenu.show()/menu.close(), and the menu’smdClose(fired on outside click,Escape, or item pick) resetstrailingChecked— the one sync the component can’t do itself. - Confidence is never color-alone. The meter carries
role="meter"with the full ARIA value contract, and the script writes the severity band into the visiblelabel(“Model confidence — high”) whenever it swaps the color role, satisfying WCAG 1.4.1. - Snackbar timing respects the reader. Auto-hide pauses while the surface is hovered or focused (WCAG 2.2.1), and re-showing the single element for each verdict means confirmations serialize instead of stacking.
Variations
Section titled “Variations”- Undo the verdict: set
action="Undo"on the snackbar,hide('action')from themdActionhandler, and re-insert the previous report at the front of the queue whenmdClosereportsreason: 'action'. - Multi-reason triage: switch the reason chips to
variant="filter"and let reviewers toggle which reasons they’re confirming; the verdict payload then carries the checked subset. - Keyboard-first review: the segmented set is already three tab stops with
arrow-key selection; add a
keydownshortcut layer (A/E/R) on the card for high-volume queues. - Circular confidence: in a denser layout,
md-meter variant="circular" size="56" show-valueturns the reading into a compact ring beside the excerpt instead of a full-width bar.
Related
Section titled “Related”- Recipe: Two-factor verification — another single-decision screen with verification states and snackbar-style feedback patterns.
- Component manuals:
md-split-buttonfor the menu-sync contract,md-meterfor formatting and threshold-color guidance, andmd-segmented-buttonfor the selection model.