Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
01a0def
feat(shared): add the refusal reasons and the schemas of the moderati…
Nascimento95 Oct 12, 2026
e1e9d20
feat(api): add the moderation history of a route and details to API e…
Nascimento95 Oct 12, 2026
b65af04
feat(api): serve the moderation queue and the decisions of the team
Nascimento95 Oct 12, 2026
835f6b5
test(api): cover the moderation queue, its filters and concurrent dec…
Nascimento95 Oct 12, 2026
5ba444d
feat(api-client): read the moderation queue and send the decisions
Nascimento95 Oct 12, 2026
e2b6519
feat(ui): add the search field and the filter with a menu
Nascimento95 Oct 12, 2026
0f38341
feat(admin): read remote data for a screen and map the queue address …
Nascimento95 Oct 12, 2026
82fcd73
feat(admin): show the moderation queue with its filters, sort and sea…
Nascimento95 Oct 12, 2026
009f20d
feat(shared): describe the moderation sheet of a route and the change…
Nascimento95 Oct 12, 2026
ed7eecf
feat(api): serve the moderation sheet of a route, with its author, hi…
Nascimento95 Oct 12, 2026
2ef7a29
feat(api-client): read the moderation sheet of a route
Nascimento95 Oct 12, 2026
beeff34
feat(ui): add the checkbox, the initial avatar and a non-destructive …
Nascimento95 Oct 12, 2026
d5cb19a
fix(admin): keep the window from scrolling under the page on long scr…
Nascimento95 Oct 12, 2026
e842a76
feat(admin): add the texts, the step plan layout and the lateness of …
Nascimento95 Oct 12, 2026
600d1f2
feat(admin): show a route as it will be published on its moderation s…
Nascimento95 Oct 12, 2026
d5e4f9c
feat(admin): show the author, the history and the changes of a revision
Nascimento95 Oct 12, 2026
0ad9a3f
feat(admin): decide on a route from its moderation sheet (A-02)
Nascimento95 Oct 12, 2026
6add321
test(api): keep the moderation test routes out of the search zones
Nascimento95 Oct 13, 2026
875f393
fix(api): count as late only a route waiting for its first look
Nascimento95 Oct 14, 2026
fbfbb50
fix(admin): tell the truth about the author, explain a refused messag…
Nascimento95 Oct 14, 2026
be6ad5a
feat(ui): add a radio group with an explanation under each choice
Nascimento95 Oct 14, 2026
a4e769a
feat(admin): show the seven refusal reasons with their explanation (A…
Nascimento95 Oct 14, 2026
6135674
feat(admin): keep the sheet after a publication and move the city und…
Nascimento95 Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/admin/src/app/(shell)/moderation/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ModerationSheetContainer } from '@/containers/ModerationSheetContainer';

/** A-02, the moderation sheet of a route, on its own page. */
export default async function ModerationSheetPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <ModerationSheetContainer key={id} routeId={id} />;
}
11 changes: 11 additions & 0 deletions apps/admin/src/app/(shell)/moderation/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ModerationQueueContainer } from '@/containers/ModerationQueueContainer';
import { Suspense } from 'react';

/** A-02. The state of the queue is read from the address, in the browser only. */
export default function ModerationPage() {
return (
<Suspense>
<ModerationQueueContainer />
</Suspense>
);
}
79 changes: 60 additions & 19 deletions apps/admin/src/components/confirm/ReasonFields.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { messages } from '@/messages/fr';
import { Field, Select, Textarea } from '@widoo/ui';
import { Field, RadioGroup, Select, Textarea } from '@widoo/ui';

export type ReasonOption = { code: string; label: string };
export type ReasonOption = { code: string; label: string; description?: string };

type ReasonFieldsProps = {
/** Prefix of the `id` of the two controls, unique on the page. */
Expand All @@ -12,6 +12,17 @@ type ReasonFieldsProps = {
onReasonCodeChange: (code: string) => void;
detail: string;
onDetailChange: (detail: string) => void;
/** Label and help of the detail when it is not a note for the team: a message to an author. */
detailLabel?: string;
detailHint?: string;
/** Shown instead of the hint when the detail is refused (empty). */
detailError?: string;
detailPlaceholder?: string;
/**
* `select` (default): a list of the reasons. `radios`: every reason on view with its
* explanation, for the few reasons an author reads (the refusal of a route).
*/
reasonLayout?: 'select' | 'radios';
};

/**
Expand All @@ -25,31 +36,61 @@ export function ReasonFields({
onReasonCodeChange,
detail,
onDetailChange,
detailLabel = messages.confirm.detail,
detailHint = messages.confirm.detailHint,
detailError,
detailPlaceholder,
reasonLayout = 'select',
}: ReasonFieldsProps) {
const texts = messages.confirm;
return (
<>
<Field label={texts.reason} htmlFor={`${id}-reason`} isRequired>
<Select
id={`${id}-reason`}
required
{reasonLayout === 'radios' ? (
<RadioGroup
name={`${id}-reason`}
legend={texts.reason}
isRequired
options={options.map(({ code, label, description }) => ({
value: code,
label,
description,
}))}
value={reasonCode}
onChange={(event) => onReasonCodeChange(event.target.value)}
>
<option value="" disabled>
{texts.chooseReason}
</option>
{options.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
onChange={onReasonCodeChange}
/>
) : (
<Field label={texts.reason} htmlFor={`${id}-reason`} isRequired>
<Select
id={`${id}-reason`}
required
value={reasonCode}
onChange={(event) => onReasonCodeChange(event.target.value)}
>
<option value="" disabled>
{texts.chooseReason}
</option>
))}
</Select>
</Field>
<Field label={texts.detail} htmlFor={`${id}-detail`} isRequired hint={texts.detailHint}>
{options.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</Select>
</Field>
)}
<Field
label={detailLabel}
htmlFor={`${id}-detail`}
isRequired
hint={detailHint}
error={detailError}
>
<Textarea
id={`${id}-detail`}
required
// A refused empty detail is told by the field itself, not by the bubble of the browser.
required={detailError === undefined && reasonLayout === 'select'}
maxLength={1000}
placeholder={detailPlaceholder}
aria-invalid={detailError ? true : undefined}
aria-describedby={`${id}-detail-hint`}
value={detail}
onChange={(event) => onDetailChange(event.target.value)}
Expand Down
12 changes: 12 additions & 0 deletions apps/admin/src/components/moderation/CertifiedSeal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { messages } from '@/messages/fr';
import { SealCheckIcon } from '@phosphor-icons/react/dist/ssr';

/** Blue seal next to the name of a certified creator (D-023), named for screen readers. */
export function CertifiedSeal() {
return (
<>
<SealCheckIcon aria-hidden weight="fill" className="size-16 shrink-0 text-primary" />
<span className="sr-only">, {messages.moderation.certified}</span>
</>
);
}
54 changes: 54 additions & 0 deletions apps/admin/src/components/moderation/ModerationQueueFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { messages } from '@/messages/fr';
import type { ModerationQueue } from '@widoo/shared';
import { FilterChip, FilterMenu, type FilterMenuOption } from '@widoo/ui';

export type ModerationFilterChoice = 'pending' | 'changesRequested' | 'late' | 'revisions';

type ModerationQueueFiltersProps = {
/** Size of each filter over the whole queue; absent while the queue loads. */
counts?: ModerationQueue['counts'];
/** The filters that apply now. */
active: Record<ModerationFilterChoice, boolean>;
onToggle: (filter: ModerationFilterChoice) => void;
/** Cities that have a route in the queue. */
cities: readonly FilterMenuOption[];
/** Identifier of the city chosen, null without a choice. */
cityId: string | null;
onCityChange: (cityId: string | null) => void;
};

const choices: ModerationFilterChoice[] = ['pending', 'changesRequested', 'late', 'revisions'];

/** Filters of the moderation queue, as chips with the number of routes each one holds. */
export function ModerationQueueFilters({
counts,
active,
onToggle,
cities,
cityId,
onCityChange,
}: ModerationQueueFiltersProps) {
return (
<div
role="group"
aria-label={messages.moderation.filters.label}
className="flex flex-wrap gap-8"
>
{choices.map((choice) => (
<FilterChip
key={choice}
label={messages.moderation.filters[choice]}
count={counts?.[choice]}
isActive={active[choice]}
onClick={() => onToggle(choice)}
/>
))}
<FilterMenu
label={messages.moderation.filters.city}
options={cities}
value={cityId}
onChange={onCityChange}
/>
</div>
);
}
134 changes: 134 additions & 0 deletions apps/admin/src/components/moderation/ModerationQueueTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { ModerationColumn } from '@/config/moderation-list';
import { formatDuration } from '@/lib/dates';
import { messages } from '@/messages/fr';
import { HourglassIcon } from '@phosphor-icons/react/dist/ssr';
import type { ModerationQueueItem } from '@widoo/shared';
import {
SortableColumnHeader,
StatusBadge,
Table,
TableCell,
TableHeaderCell,
TableRow,
TableSkeleton,
type SortDirection,
} from '@widoo/ui';
import { CertifiedSeal } from './CertifiedSeal';
import { WaitingTime } from './WaitingTime';
import { WarningCount } from './WarningCount';

type ModerationQueueTableProps = {
/** Rows of the page; ignored while `isLoading`. */
items: ModerationQueueItem[];
isLoading: boolean;
sort: { column: ModerationColumn; direction: SortDirection };
onSort: (column: ModerationColumn) => void;
/** True for a route that has been waiting for more than 72 hours. */
isLate: (item: ModerationQueueItem) => boolean;
/** Opens the moderation sheet of a route. */
onOpen: (item: ModerationQueueItem) => void;
};

const texts = messages.moderation;
const columnCount = 7;

/** The moderation queue as a table (A-02): one route per row, sortable by five columns. */
export function ModerationQueueTable({
items,
isLoading,
sort,
onSort,
isLate,
onOpen,
}: ModerationQueueTableProps) {
// From 1 024 px to 1 279 px the city moves under the title (planche « liste à 1 024 px »).
const renderSortableHeader = (column: ModerationColumn, label: string, className?: string) => {
const direction = sort.column === column ? sort.direction : null;
const ariaSort = direction === null ? 'none' : direction === 'asc' ? 'ascending' : 'descending';
return (
<TableHeaderCell aria-sort={ariaSort} className={className}>
<SortableColumnHeader direction={direction} onSort={() => onSort(column)}>
{label}
</SortableColumnHeader>
</TableHeaderCell>
);
};

return (
<Table>
<thead>
<tr>
{renderSortableHeader('parcours', texts.columns.route)}
{renderSortableHeader('auteur', texts.columns.author)}
{renderSortableHeader('ville', texts.columns.city, 'hidden xl:table-cell')}
<TableHeaderCell>{texts.columns.content}</TableHeaderCell>
{renderSortableHeader('alertes', texts.columns.warnings)}
<TableHeaderCell>{texts.columns.unverified}</TableHeaderCell>
{renderSortableHeader('file', texts.columns.waiting, 'text-right')}
</tr>
</thead>
<tbody>
{isLoading ? (
<TableSkeleton rows={8} columns={columnCount} label={messages.list.loading} />
) : (
items.map((item) => {
const submission = item.isRevision ? texts.revision : texts.firstSubmission;
return (
<TableRow key={item.id} onOpen={() => onOpen(item)}>
<TableCell className="py-8">
<span className="flex flex-col">
<span className="text-control">{item.title}</span>
<span className="flex flex-wrap items-center gap-8 text-label text-muted-foreground">
<span className="xl:hidden">
{item.cityName}, {submission.toLowerCase()}
</span>
<span className="hidden xl:inline">{submission}</span>
{item.status === 'changes_requested' ? (
<StatusBadge
tone="neutral"
icon={<HourglassIcon aria-hidden className="size-16" />}
>
{texts.toFix}
</StatusBadge>
) : null}
</span>
</span>
</TableCell>
<TableCell>
{item.author ? (
<span className="inline-flex items-center gap-6">
{item.author.firstName}
{item.author.isCertified ? <CertifiedSeal /> : null}
</span>
) : (
<span className="text-muted-foreground">{texts.noAuthor}</span>
)}
</TableCell>
<TableCell className="hidden xl:table-cell">{item.cityName}</TableCell>
<TableCell>
{texts.content(
item.stepCount,
item.durationMin === null ? null : formatDuration(item.durationMin),
)}
</TableCell>
<TableCell>
<WarningCount count={item.warningCount} />
</TableCell>
<TableCell>
{item.unverifiedPlaceCount > 0 ? (
texts.unverified(item.unverifiedPlaceCount)
) : (
<span className="text-muted-foreground">{texts.noUnverified}</span>
)}
</TableCell>
<TableCell className="text-right">
<WaitingTime since={new Date(item.waitingSince)} isLate={isLate(item)} />
</TableCell>
</TableRow>
);
})
)}
</tbody>
</Table>
);
}
22 changes: 22 additions & 0 deletions apps/admin/src/components/moderation/WaitingTime.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { formatWaitingTime } from '@/lib/dates';
import { messages } from '@/messages/fr';
import { WarningCircleIcon } from '@phosphor-icons/react/dist/ssr';
import { cn } from '@widoo/ui';

type WaitingTimeProps = {
/** Since when the route waits in the queue. */
since: Date;
/** Waiting for more than 72 hours: red, with an icon and « en retard » for screen readers. */
isLate: boolean;
};

/** Time spent in the queue: « 9 h », « 3 j 4 h ». Lateness is never told by the red alone. */
export function WaitingTime({ since, isLate }: WaitingTimeProps) {
return (
<span className={cn('inline-flex items-center gap-6', isLate && 'font-bold text-destructive')}>
{isLate ? <WarningCircleIcon aria-hidden weight="fill" className="size-16" /> : null}
{formatWaitingTime(since)}
{isLate ? <span className="sr-only">, {messages.moderation.late}</span> : null}
</span>
);
}
15 changes: 15 additions & 0 deletions apps/admin/src/components/moderation/WarningCount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { messages } from '@/messages/fr';
import { WarningIcon } from '@phosphor-icons/react/dist/ssr';

/** Coherence warnings still standing on a route: « 2 alertes » in amber, or « Aucune ». */
export function WarningCount({ count }: { count: number }) {
if (count === 0) {
return <span className="text-muted-foreground">{messages.moderation.noWarning}</span>;
}
return (
<span className="inline-flex items-center gap-6 font-bold text-alert">
<WarningIcon aria-hidden weight="fill" className="size-16" />
{messages.moderation.warnings(count)}
</span>
);
}
Loading
Loading