mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
2f3c556c7b
* feat(apps): the manage/table routes SPEND the ultrawide width instead of padding it PR #4615 raised APPS_PAGE_CONTAINER_WIDTH 1920 -> 2560, so every route in APPS_FULL_MEASURE_PAGES went from 1888 to 2528 of content. Nothing was clipped and nothing errored -- the extra 640px simply became PADDING, which on a space-between row lands entirely between a row's content and the control that acts on it. The audit of that PR measured the concrete instance: src/pages/apps/installed.tsx's four `Group justify="space-between" wrap="nowrap"` rows each moved their button 640px further from the name it belongs to. `/apps/review` had the same shape and had been "fixed" by capping the whole page at 1368 -- refusing the width rather than spending it. Scope is "wider columns only": proportional column widths with the primary column absorbing the slack. No new fields, no new queries, no width-conditional column set. New module `~/components/Apps/appsWideLayout`, two mechanisms: * AppsTableColgroup -- a percentage `<colgroup>` per table, with exactly ONE column left `auto` so automatic table layout hands it the surplus. Six ledgers, one per table SHAPE (the review queue's Deploy column and the revenue table's App column are both decided by DATA, never by width). Wired into UnifiedReviewList (/apps/review), MyAppsBody (/apps/mine), AppListingsModerationTable and RevenuePanel (/apps/revenue). Measured at 1440 vs 2560: /apps/review's App column takes 604.81 of the container's 1120px surplus against 515.19 for the other four columns combined; against origin/main's components it took 202.27 against 917.72. /apps/mine: 616.00 against 503.99, from 322.71 against 797.29. * AppsCardGrid -- /apps/installed's card lists become `repeat(auto-fill, minmax(min(100%, 1200px), 1fr))`. 1200 is picked so the ladder steps exactly where the surplus appeared: ONE column through the old 1920 container's 1888 of content, TWO from 2416. So nothing a 1440 or 1920 monitor showed moves, and the name->Manage gap goes 1201.77 -> 1049.77 across the two fixture widths instead of 1201.77 -> 2321.77. Measures become BANDS. A fixed measure did not grow with the container, so /apps/submit rendered the same 1068px of form on a 1440 monitor and on a 2560 one. APPS_READABLE_MEASURE is now {min: 1068, max: 1368, grow: 55} and APPS_TWO_COLUMN_DETAIL_MEASURE {min: 1288, max: 1600, grow: 65}; AppsPageLayout renders them through appsMeasureCss as a clamp() whose middle term is a PERCENTAGE, so the ramp is bounded by the container's own cap. Each band's floor is the width that route already rendered at, and `grow` is chosen so the band is still AT its floor at 1888 of content and AT its ceiling by 2528 -- both asserted as arithmetic, not described in prose. The two-column ceiling is derived rather than picked: 8/12 x 1600 = 1066.67 <= the readable floor, so the store preview's markdown column stays inside the measure the readable class exists to hold. APPS_NARROW_TABLE_MEASURE is DELETED and /apps/review joins APPS_FULL_MEASURE_PAGES. The constant is asserted absent rather than merely unused -- a surviving export with no consumer is the shape that gets wired back in by the next reader who finds a table reading too wide. Tests * unit: __tests__/appsWideLayout.test.ts (new) pins the rule on inputs it must REJECT, every ledger's column count and primary position, and that each ledger is actually WIRED to its table. __tests__/appsPageWidths.test.ts gains the band arithmetic and the card ladder; the taxonomy, gutter and /apps/mine scroll-floor pins are updated for the band shape rather than dropped. * geometry: AppsWideLayout.geometry.test.tsx (new) measures rendered boxes with getBoundingClientRect at TWO named container widths (1440 -> 1408 of content, 2560 -> 2528), for two table routes plus the installed card row. It is in the `geometry` project because every number depends on Mantine's Container cap, Table's width and the cascade LAYER ORDER, none of which the `component` tier loads. Two comments were corrected because measurement refuted them. "A browser ignores a misplaced <colgroup>" is FALSE here: moving the element after <Table.Tbody> changed no rendered width at all (React inserts via the DOM API, so the parser's table foster-parenting never runs). The ordering is a validity rule enforced structurally, and the geometry file says so. The geometry file also states what it does NOT prove: it supplies the card grid itself, so it cannot see /apps/installed reverting to a Stack -- that claim is the unit wiring guard's. * docs(test): the geometry tier HAS a CI job now — the harness said it had none `.github/workflows/lint.yml` gained a `geometry:` job (`Geometry tests`) that installs Chromium, runs `vitest run --project geometry` and asserts a collected-count floor, but `test/geometry-setup.tsx` still opened with "WHAT RUNS THIS, TODAY: NOTHING. SAY IT OUT LOUD." That is the rot the paragraph was written to prevent, arriving from the other direction: it would talk the next reader out of relying on a gate that exists. Observed on this PR - the check is in the list. Replaced with what is actually true, including the half that is easy to overstate: the job carries `continue-on-error: ${{ github.event_name == 'pull_request' }}`, so it REPORTS on a PR and BLOCKS on a push to `main`. `component` really does remain ungated. * style(apps): prettier on the two files this PR made unformatted `ESLint + Prettier (changed files)` went red on the ADDED-file half, which is the blocking half. Two files, one reason each: * `__tests__/appsWideLayout.test.ts` is ADDED, so the gate is real; one over-long regex property needed wrapping. * `__tests__/appsPageWidths.test.ts` is MODIFIED (report-only) but was prettier-CLEAN at origin/main, so this PR is what made it dirty. Fixed rather than shrugged off. `UnifiedReviewList.tsx` is deliberately NOT reformatted: it was already prettier-dirty at origin/main (verified by checking out that revision at the repo path, not from /tmp -- prettier resolves its config from the file path, so a copy outside the repo is graded under a DIFFERENT config and reported every single-quoted import as unformatted). The workflow makes modified files report-only for exactly this reason; reformatting it here would be a whole-file reformat unrelated to the change. * ci: retrigger against the fixed shard gate (empty) * fix(apps): the uncap exposed FIVE tables, not two — enumerate them instead of remembering Round-1 audit payload (F1). Removing `/apps/review`'s 1368 body cap is a claim about EVERY table on that route, and the first pass ledgered the two somebody had in mind. The cap was the only thing holding the others down. Measured on `ActivePreviewsPanel` in the real layout, 1440 -> 2560, with no ledger: columns 228.02 | 165.17 | 146.45 | 152.05 | 682.31 413.89 | 299.83 | 265.84 | 276.00 | 1238.44 slug -> "Tear down" gap 609.67 -> 1173.55 (+563.88) i.e. the change re-opened, on that table, exactly the defect it exists to remove. Five tables were exposed once the route set is walked rather than recalled: ActivePreviewsPanel and OffsiteReportsQueue (/apps/review), AppActivityPanel (/apps/installed) and ReportTabs (/apps/review/<id>, via OnsiteReviewModalBody -> AgentReviewPanel). All four now carry ledgers; MySubmissionsList, OffsiteSubmissionsList and the dead OffsiteReviewQueue table are EXEMPT, and the exemption is re-derived by searching src for a render of each. THE PRIMARY-COLUMN RULE HAS TWO CASES NOW, because applying the general one to ActivePreviewsPanel made the defect WORSE. Its App cell is a short `<Code>{slug}</Code>`, so making it primary put 49% of the table between the slug and the button. Written down: (a) a column with genuinely variable long content is primary (slack becomes headroom); (b) a table where NO column has that takes its ACTION column instead, so the slack lands past the button rather than in front of it. And the fixed shares are sized to CONTENT, not spread to fill — a share larger than its cell needs is padding relabelled. For a case-(b) table they are deliberately below min-content (the shrink-to-content idiom), which is what makes those columns constant: content-sized shares (9/7/6/6) still moved the gap 510.38 -> 823.95; 3/2/2/2 holds it at 446.97 -> 446.97, with the action column taking 1120 of 1120 and the data columns 0. Every ledger re-tuned on the same reasoning. GUARDS. The hand-written four-file list is replaced by an AST walk over every `.tsx` under `src/components/{Apps,AppBlocks}`: each `<Table>` with a header row must carry a colgroup as its own FIRST CHILD or be exempted by name, and its ledger length(s) must equal the header cells the table actually renders. That one guard closes four findings at once: · F1 coverage is derived, so a table nobody mentioned cannot be missed. · F4 "each ledger has the column COUNT its table renders" was a relationship in the title and a literal in the body. Adding a `<Table.Th>` without touching the ledger was green at BOTH tiers for AppListingsModerationTable and RevenuePanel, and `[null,5,5,5,5]` passed everything. The table is read now. · the structural guard was SPELLED: `indexOf('<AppsTableColgroup')` vs `indexOf('<Table.Thead')` passes a colgroup hoisted OUTSIDE the `<Table>` (equally inert), and reads the first occurrence in the FILE, so the second table in OffsiteReviewQueue.tsx was graded against the first one's colgroup. · the set is a LEDGER (11 entries, two of them in one file), so it fails when it grows or shrinks rather than only when it empties. F2 — `/apps/installed`'s adoption guard matched `/<AppsCardGrid\b/` over the whole file, and this PR added two OTHER call sites to that file, so it stopped seeing the case it exists for: reverting ONLY the installed-apps list to `<Stack gap="md">` passed unit 124/124 and geometry 27/27. Now keyed on each list's own testId. F3 — the retracted "a browser ignores a misplaced colgroup" belief was still attached to the guard it justifies, in the two places a reader lands. Both rewritten; the geometry file now states the measured split (placement + column count are the unit tier's, widths are its own) instead of claiming the unit tier sees neither. F5 — `min(100%, N)` was documented load-bearing and unguarded: dropping it survived both tiers because neither fixture is under 1200px. At 390x844 the real behaviour is worse than the docstring said — gridBox 358, child 1200, `document.scrollWidth` unchanged, i.e. CLIPPED with no scrollbar rather than "overflows horizontally". Corrected, and pinned at a phone viewport with a positive control that the fixture can see the defect. F6 — `APPS_TWO_COLUMN_DETAIL_MEASURE` is a band reaching 1600, so PageBlockHost's "1600 is above every ordinary content measure" is parity, not headroom. The conclusion survives (no app is narrower than the page that launched it); the margin does not. Both the constant's doc and its guard say so. F7 — three space-between rows, not four: `ScopeGrantsPanel`'s has a single flex child and no control, so nothing moved there. Named by function rather than line number, since this PR's own edits moved every line first quoted. F8 — `AppsCardGrid` takes a `gap`. The Hidden tab was `<Stack gap="sm">` (12px) and defaulting it to 16 would have moved something a 1440 monitor shows, against this module's own "nothing changes" claim. Both gaps give the same ladder at both container widths. * fix(apps): two primary columns that could not use the width — measured, not reasoned Round-2 audit payload. Both ledgers added last round picked a primary column on what the FIELD MEANS rather than on what the CELL CAN DO, and rendering them settled it. F-B `OffsiteReportsQueue`. `Reason` was primary because it is "operator-written free text". It is `lineClamp={2} style={{ maxWidth: 260 }}`. Measured 1440 -> 2560: columns 140.59 | 587.73 | 90.36 | 98.41 | 74.8 | 414.11 252.59 | 1404.64 | 151.55 | 176.81 | 126.3 | 414.11 details box 260 -> 260 The primary column took +816.91 and the sentence gained nothing — "padding relabelled", which this module forbids two paragraphs above the ledger. `App` was the earlier guess and is a genuine case-(a) candidate (its uncapped listing NAME grows, glyph 98.05 -> 199.45), just far too small to absorb ~1350px. When no cell can, it is case (b): the actions column takes the slack. Measured after: Status -> first button 32 -> 32. That required flipping the action `Group` from `justify="flex-end"` to `flex-start`. It is a no-op wherever that column sits at min-content (414.11 at BOTH widths before the ledger), i.e. everywhere it is not primary — and without it case (b) IS the defect: measured with `flex-end` restored, the same gap goes 329.44 -> 1093.69. F-C `AppActivityPanel`. `Detail` was primary; the component's own comment at the render site says the ACTION cell carries the human sentence and DETAIL always holds the raw technical ref. Measured on a rich `tip` row: Action glyph "Tipped 500 Buzz to user #4242" 102.97 -> 166.34 (was being WRAPPED) Detail glyph "POST /api/v1/buzz/tip" 151.72 -> 151.72 (fixed token) `Detail` took +772.78 for a token that cannot use a pixel of it. The fix is NOT "make Action primary" — the sentence is variable but BOUNDED, so ~1800px would park 1500px of dead space mid-row. Action gets a generous fixed share and the LAST column trails. Both re-tunes were then measured AGAIN and both had squeezed a cell at 1440 — the failure this module's provenance rule exists to prevent, introduced by the fix for the previous one. `Reason` at 12% rendered a 136.72 details box at 1408 instead of 260; `Detail` at 7% wrapped its monospace ref (glyph 115.59 at 1408 vs 151.72 at 2528). Both shares are now set from the NARROW end (21% / 13%). The residue is ~239px inside `Reason` at 2528, which is the honest cost of a hard-capped cell in a fluid table and smaller than any alternative measured. Case (b) is generalised in the module: it is the LAST column (usually the action column), and it only works if that cell is left-aligned. GUARDS F-D the primary-position pin had gone 6/6 -> 6/10 when four ledgers were added, and that gap is what let F-B and F-C ship: measured, moving the primary in all four new ledgers left the unit tier 31/31 green and geometry caught only `ActivePreviewsPanel`. It is keyed off `APPS_TABLE_COLUMN_LEDGERS` now, so a ledger cannot be added without a decision. Both re-decided tables gained geometry arms, which is also the coverage gap round 2 listed as "not verified". F-E the F8 gap equivalence was cited as "pinned" and "asserted" and was neither — every `appsCardGridColumnsAt` call site used the default gap, and deleting `gap={12}` from the Hidden tab left the tier 31/31 green. Now asserted at both gaps with a guard-the-guard that a ladder-changing gap DOES disagree, plus the call site, plus a browser-side reading of the resolved `column-gap`. The `data-apps-card-grid-gap` attribute had zero consumers and is removed rather than left as decoration. F-A the no-ledger baseline quoted in five places (including a live assertion message) was `609.67 -> 1173.55`, which is the `<Code>` border box to the row's FIRST control. The shipped helper measures the slug's GLYPH RANGE to the "Tear down" BUTTON: `817.36 -> 1381.23`. The delta is +563.87 either way so the argument is untouched, but a message that quoted one pair while printing the other reads as a broken harness. F-F the guard's non-coverage list was written as closed ("stated rather than implied") and was not — a NEW file can still escape by import-aliasing `Table`, or by extracting its header row into a sibling component. Both are stated; neither is fixed, because widening the walk to resolve aliases and cross-component structure is a parser, and this repo's `--header-height` guard records five rounds in which each parser added to close a hole shipped a new false PASS. Existing tables cannot escape either way — the SITES ledger is an exact list and reds on a shrink. Also: the "all eight geometry assertions" figure is de-quoted in three places (the file has grown twice since), and the mutation it describes was re-confirmed at the new size. * fix(apps): give the tier HEIGHT and a narrow width — then two ledgers had to go Round-3 audit payload. Round 2 picked a wrong primary; round 3 fixed the primary and squeezed the columns. Each time the guard could not see the next failure, because every geometry arm read a WIDTH at 1440/2560 only — and a column squeezed below its content does not get narrower than a width assertion expects, it gets TALLER. THE GUARD FIRST, because it is what stops a round 5. · two more viewports, 768 and 1200 — a percentage share is smallest in absolute px at the narrow end, which is where a share sized from a 1408 measurement bites; · row HEIGHT and LINE COUNT, not just width; · and the invariant is an A/B against the SAME TREE WITH ITS `<colgroup>` DETACHED, at each of the four widths. Not against a literal (it would rot on any copy change) and not against the other widths (rows legitimately wrap more at 768 for ANY table, so "no taller than at 2560" is a claim no correct table could satisfy). What a ledger must never do is make a row taller than the browser's own layout at THAT width. It found three more instances of the class the moment it was written, in ledgers this PR shipped in rounds 2 and 3. That is the guard working, not a new defect. WHAT THE MEASUREMENTS THEN FORCED `AppActivityPanel` — row height 36.19 natural, 48.09 under round 2's ledger, 64.89 under round 3's, at 768/1200/1440. `When` broke a `YYYY-MM-DD HH:mm` stamp across THREE lines and `App` sat pinned at its 108.52 min-content from 768 through 2560, so a long name was ellipsised identically at both ends. NO LEDGER FIXES IT: this table's max-content sum (~735px) is the container's content width AT 768, so there is no surplus to place at the narrow end. Three candidates sized from 1200 still wrapped at 768; the one configuration that holds a single line everywhere — [16, 12, 27, 25, null] — reproduces natural layout at 2560 to within ~15px on three of five columns. The ledger is DELETED and the table is EXEMPT. `OffsiteReportsQueue` — same conclusion, three wrong ledgers later. At 1200 its row wants App 240 + Reason 292 + Reporter 94 + Reported 133 + Status 86 + actions 414 = 1259px in 1168px, so something is under-served whatever the split. Every candidate was taller than natural at 1200 (105.48 / 177.88 against 88.69) or clipped the `lineClamp={2}` details harder than natural (a 150.77px details box against 260) — and the second is INVISIBLE to a row-height check, which is why the tier now A/Bs WIDTH as well. Ledger deleted, table EXEMPT, and its `justify="flex-end"` restored: that flip was correct only as part of case (b), and with no ledger the column sits at min-content where both alignments render identically. `ActivePreviewsPanel` and `UnifiedReviewList` KEEP their ledgers — their regressions were fixable. Both were the same root cause: a share deliberately below min-content only holds one line when min-content is the WHOLE label. `white-space: nowrap` on the state badge, the relative-age label, the kind badge and the submitted-at stamp makes that true, and all four are labels that should never have wrapped. Previews +3.59 → 0 at every width; the queue +12.2 → 0 at 768/1200/1440. EXEMPTIONS NOW HAVE TWO KINDS, EACH VERIFIED `unrendered` is re-derived by searching src for a JSX render, as before. `no-surplus` is a table that IS rendered, so that check would reject it; what it must carry instead is a NAMED geometry arm that keeps taking the measurement the exemption rests on. Deleting the arm turns the exemption red rather than leaving it an unmeasured claim. Both branches are exercised, and the guard asserts so. CORRECTIONS from the round-3 audit F3 — "the flip is a no-op everywhere it is not primary" was true only under the previous ledger; with NO colgroup the actions column measures 414.16 at 1440 and 728.39 at 2560. The sentence is gone with the flip it justified. F4 — the App-rejection figure (glyph 98.05 → 199.45) was measured with App held at 10%; given room the same fixture reaches 331.3. That paragraph is gone with the ledger. Plus the two grammar breaks from de-quoting the geometry count.
542 lines
21 KiB
TypeScript
542 lines
21 KiB
TypeScript
import { Tabs } from '@mantine/core';
|
|
import { IconCheck, IconClipboardList, IconClock, IconFlag, IconX } from '@tabler/icons-react';
|
|
import { useRouter } from 'next/router';
|
|
import { useCallback, useMemo, useState } from 'react';
|
|
import { NotFound } from '~/components/AppLayout/NotFound';
|
|
import { ActivePreviewsPanel } from '~/components/Apps/ActivePreviewsPanel';
|
|
import { AppListingsModerationTable } from '~/components/Apps/AppListingsModerationTable';
|
|
// The off-site review MODAL is now PAGE-OWNED (lifted here) so a single instance is
|
|
// shared by the unified Pending list AND the `AppListingsModerationTable` — no
|
|
// divergence. `OffsiteReportsQueue` still powers the Reports tab.
|
|
import {
|
|
OffsiteReportsQueue,
|
|
OffsiteReviewModal,
|
|
type OffsitePendingRow,
|
|
} from '~/components/Apps/OffsiteReviewQueue';
|
|
// The on-site (App Block) review modal + its request types were EXTRACTED to
|
|
// `OnsiteReviewModal.tsx` so the modal is importable into a browser test WITHOUT
|
|
// this page's `getServerSideProps` tRPC-server graph.
|
|
import {
|
|
OnsiteReviewModal,
|
|
type AnyRequest,
|
|
type OnsiteReviewMode,
|
|
} from '~/components/Apps/OnsiteReviewModal';
|
|
import {
|
|
CombinedReviewModal,
|
|
type CombinedReviewSelection,
|
|
} from '~/components/Apps/CombinedReviewModal';
|
|
import { UnifiedReviewList } from '~/components/Apps/UnifiedReviewList';
|
|
import type {
|
|
CombinedReviewPayload,
|
|
OffsiteReviewRequest,
|
|
OnsiteReviewRequest,
|
|
} from '~/components/Apps/unifiedReviewRow';
|
|
import { Meta } from '~/components/Meta/Meta';
|
|
import { AppsPageLayout } from '~/components/Apps/AppsPageLayout';
|
|
import { EMBEDDED_KIND_LABEL, STANDALONE_KIND_LABEL } from '~/components/Apps/listingKindLabels';
|
|
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
|
|
import { isAppReviewer } from '~/shared/utils/app-blocks-access';
|
|
import { createServerSideProps } from '~/server/utils/server-side-helpers';
|
|
import { getLoginLink } from '~/utils/login-helpers';
|
|
import { showErrorNotification, showSuccessNotification } from '~/utils/notifications';
|
|
import { trpc } from '~/utils/trpc';
|
|
|
|
/**
|
|
* /apps/review — Moderator review queue + history for Apps (on-site App Blocks AND
|
|
* off-site external listings), UNIFIED into one list per tab.
|
|
*
|
|
* Five tabs:
|
|
* - Pending — ONE oldest-first FIFO list interleaving on-site publish requests
|
|
* (`blocks.listPendingRequests`) + off-site requests
|
|
* (`appListings.listPendingRequests`). Each row carries a kind badge
|
|
* (App / External) and a Review action that opens the CORRECT modal.
|
|
* - Approved — unified newest-first history (on-site + off-site approved requests).
|
|
* - Rejected — unified newest-first history (on-site + off-site rejected requests).
|
|
* - Reports — off-site listing report queue + mod takedown actions (unchanged).
|
|
* - Manage listings — the full all-status lifecycle table (reset/relist/claim/purge).
|
|
*
|
|
* Both review modals are PAGE-OWNED (lifted here): the on-site `OnsiteReviewModal`
|
|
* and the off-site `OffsiteReviewModal`. The unified list + the management table
|
|
* both call the page's `openOnsiteReview` / `openOffsiteReview` — so there is exactly
|
|
* ONE instance of each modal and the kinds never cross. This is presentation +
|
|
* modal-state lifting only: no server proc, modal internal, or approve/reject/
|
|
* lifecycle logic changes.
|
|
*
|
|
* Active tab is mirrored to `?tab=` so a mod can deep-link a specific view.
|
|
*
|
|
* v0 gate: requires `isAppReviewer`. Dark + mod-only.
|
|
*/
|
|
export const getServerSideProps = createServerSideProps({
|
|
useSession: true,
|
|
resolver: async ({ features, session, ctx }) => {
|
|
if (!features?.appBlocks) return { notFound: true };
|
|
if (!session?.user) {
|
|
return {
|
|
redirect: {
|
|
destination: getLoginLink({ returnUrl: ctx.resolvedUrl }),
|
|
permanent: false,
|
|
},
|
|
};
|
|
}
|
|
if (!isAppReviewer(session.user)) {
|
|
return { notFound: true };
|
|
}
|
|
return { props: {} };
|
|
},
|
|
});
|
|
|
|
type TabValue = 'pending' | 'approved' | 'rejected' | 'reports' | 'manage';
|
|
|
|
function isTabValue(v: unknown): v is TabValue {
|
|
return (
|
|
v === 'pending' || v === 'approved' || v === 'rejected' || v === 'reports' || v === 'manage'
|
|
);
|
|
}
|
|
|
|
/** Rows fetched per source per page (bounded by each proc's schema at ≤100). Mod
|
|
* queues are low/moderate volume, so one bounded page per source + Load-more is
|
|
* simple and correct. */
|
|
const PAGE_LIMIT = 50;
|
|
|
|
/** Append `page` onto `accumulated`, dropping ids already present (defensive dedup
|
|
* in case Load-more double-fires before a fetch settles). */
|
|
function mergeById<T extends { id: string }>(accumulated: T[], page: T[]): T[] {
|
|
const seen = new Set(accumulated.map((r) => r.id));
|
|
return [...accumulated, ...page.filter((r) => !seen.has(r.id))];
|
|
}
|
|
|
|
export default function ReviewQueuePage() {
|
|
const features = useFeatureFlags();
|
|
const router = useRouter();
|
|
|
|
// Sync active tab with `?tab=` so deep-links land on the right view. Shallow
|
|
// routing so the page query doesn't re-trigger getServerSideProps.
|
|
const tab: TabValue = useMemo(() => {
|
|
const qt = router.query.tab;
|
|
if (typeof qt === 'string' && isTabValue(qt)) return qt;
|
|
return 'pending';
|
|
}, [router.query.tab]);
|
|
|
|
const setTab = (next: TabValue) => {
|
|
void router.replace(
|
|
{ pathname: router.pathname, query: { ...router.query, tab: next } },
|
|
undefined,
|
|
{ shallow: true }
|
|
);
|
|
};
|
|
|
|
// On-site review modal selection (page-owned). `onActioned` lets the opening tab
|
|
// refresh its own paginated query after an approve/reject (symmetric with off-site).
|
|
const [selected, setSelected] = useState<{
|
|
request: AnyRequest;
|
|
mode: OnsiteReviewMode;
|
|
onActioned?: () => void | Promise<void>;
|
|
} | null>(null);
|
|
// Off-site review modal — LIFTED to the page so one instance is shared by the
|
|
// unified Pending list and the management table. `onActioned` lets whichever
|
|
// surface opened it refresh its own paginated query after an approve/reject;
|
|
// `readOnly` makes a history-tab (Approved/Rejected) open a read-only detail view
|
|
// (no Approve/Reject buttons) — matching the on-site history posture.
|
|
const [offsiteReview, setOffsiteReview] = useState<{
|
|
row: OffsitePendingRow;
|
|
onActioned?: () => void | Promise<void>;
|
|
readOnly?: boolean;
|
|
} | null>(null);
|
|
// Combined code+media review surface — opened when a PENDING row is an app that has
|
|
// BOTH a pending code request AND a pending listing-media revision (page-owned, one
|
|
// instance). Each stacked section keeps its own independent approve/reject.
|
|
const [combinedReview, setCombinedReview] = useState<CombinedReviewSelection>(null);
|
|
|
|
// DUAL-PATH on-site row selection: under the `appReviewPage` flag a row NAVIGATES
|
|
// to the deep-linkable detail page `/apps/review/<id>`; with the flag off it opens
|
|
// the modal exactly as before. Off-site has no detail page → always the modal.
|
|
const linkToPage = !!features?.appReviewPage;
|
|
|
|
const openOnsiteReview = useCallback(
|
|
(request: AnyRequest, mode: OnsiteReviewMode, onActioned?: () => void | Promise<void>) => {
|
|
if (linkToPage) {
|
|
void router.push(`/apps/review/${request.id}`);
|
|
return;
|
|
}
|
|
setSelected({ request, mode, onActioned });
|
|
},
|
|
[linkToPage, router]
|
|
);
|
|
|
|
const openOffsiteReview = useCallback(
|
|
(row: OffsitePendingRow, onActioned?: () => void | Promise<void>, readOnly = false) => {
|
|
setOffsiteReview({ row, onActioned, readOnly });
|
|
},
|
|
[]
|
|
);
|
|
|
|
const openCombinedReview = useCallback(
|
|
(payload: CombinedReviewPayload, onActioned?: () => void | Promise<void>) => {
|
|
setCombinedReview({ ...payload, onActioned });
|
|
},
|
|
[]
|
|
);
|
|
|
|
if (!features?.appBlocks) return <NotFound />;
|
|
|
|
return (
|
|
<>
|
|
<Meta title="App publish-request queue — Civitai" deIndex />
|
|
{/*
|
|
🔴 NO `measure` — this page took a 1368 cap until the wide-tables pass, and
|
|
removing it is the point rather than a side effect. The cap existed because four
|
|
short columns could not spend the container, so the surplus landed as padding
|
|
between the last column and the Review button. `UnifiedReviewList` now carries a
|
|
proportional `<colgroup>` (`APPS_REVIEW_QUEUE_COLUMNS`), so the width goes into
|
|
the App column instead. Re-adding a cap here would hide that rather than help it.
|
|
*/}
|
|
<AppsPageLayout
|
|
title="App publish-request queue"
|
|
subtitle={`Moderator review for Apps. ${EMBEDDED_KIND_LABEL} + ${STANDALONE_KIND_LABEL} submissions share one queue per tab; Pending is oldest-first, history is newest-first.`}
|
|
>
|
|
<ActivePreviewsPanel />
|
|
|
|
<Tabs
|
|
value={tab}
|
|
onChange={(v) => {
|
|
if (isTabValue(v)) setTab(v);
|
|
}}
|
|
keepMounted={false}
|
|
>
|
|
<Tabs.List>
|
|
<Tabs.Tab value="pending" leftSection={<IconClock size={14} />}>
|
|
Pending
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="approved" leftSection={<IconCheck size={14} />}>
|
|
Approved
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="rejected" leftSection={<IconX size={14} />}>
|
|
Rejected
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="reports" leftSection={<IconFlag size={14} />}>
|
|
Reports
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="manage" leftSection={<IconClipboardList size={14} />}>
|
|
Manage listings
|
|
</Tabs.Tab>
|
|
</Tabs.List>
|
|
|
|
<Tabs.Panel value="pending" pt="md">
|
|
{/* ONE unified oldest-first queue: on-site + off-site pending requests. */}
|
|
<UnifiedPendingTab
|
|
openOnsiteReview={openOnsiteReview}
|
|
openOffsiteReview={openOffsiteReview}
|
|
openCombinedReview={openCombinedReview}
|
|
/>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="approved" pt="md">
|
|
<UnifiedHistoryTab
|
|
kind="approved"
|
|
openOnsiteReview={openOnsiteReview}
|
|
openOffsiteReview={openOffsiteReview}
|
|
/>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="rejected" pt="md">
|
|
<UnifiedHistoryTab
|
|
kind="rejected"
|
|
openOnsiteReview={openOnsiteReview}
|
|
openOffsiteReview={openOffsiteReview}
|
|
/>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="reports" pt="md">
|
|
{/* Off-site listing REPORT queue + mod takedown actions. Unchanged. */}
|
|
<OffsiteReportsQueue />
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="manage" pt="md">
|
|
{/* Full all-status listings MANAGEMENT table (reset/relist/claim/purge).
|
|
Its pending rows' Review action opens the same page-owned off-site
|
|
modal; its lifecycle-action modals stay local to it. */}
|
|
<AppListingsModerationTable openOffsiteReview={openOffsiteReview} />
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
</AppsPageLayout>
|
|
|
|
<OnsiteReviewModal
|
|
selection={selected}
|
|
onClose={() => setSelected(null)}
|
|
onActioned={selected?.onActioned}
|
|
/>
|
|
<OffsiteReviewModal
|
|
request={offsiteReview?.row ?? null}
|
|
onClose={() => setOffsiteReview(null)}
|
|
onActioned={offsiteReview?.onActioned}
|
|
readOnly={offsiteReview?.readOnly}
|
|
/>
|
|
<CombinedReviewModal selection={combinedReview} onClose={() => setCombinedReview(null)} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unified PENDING tab — one oldest-first list merging the on-site + off-site
|
|
// pending queues. Each source is independently keyset-paginated; Load-more
|
|
// advances whichever source(s) still have a next page, and the pure merge
|
|
// re-sorts the full accumulated set. (Global order across the two independently-
|
|
// paginated sources is exact once both first pages are loaded — fine at mod-queue
|
|
// volumes; we never build a server-side cross-table cursor.)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function UnifiedPendingTab({
|
|
openOnsiteReview,
|
|
openOffsiteReview,
|
|
openCombinedReview,
|
|
}: {
|
|
openOnsiteReview: (
|
|
req: AnyRequest,
|
|
mode: OnsiteReviewMode,
|
|
onActioned?: () => void | Promise<void>
|
|
) => void;
|
|
openOffsiteReview: (
|
|
row: OffsitePendingRow,
|
|
onActioned?: () => void | Promise<void>,
|
|
readOnly?: boolean
|
|
) => void;
|
|
openCombinedReview: (
|
|
payload: CombinedReviewPayload,
|
|
onActioned?: () => void | Promise<void>
|
|
) => void;
|
|
}) {
|
|
const features = useFeatureFlags();
|
|
const enabled = !!features?.appBlocks;
|
|
|
|
const [onsiteCursor, setOnsiteCursor] = useState<string | undefined>(undefined);
|
|
const [offsiteCursor, setOffsiteCursor] = useState<string | undefined>(undefined);
|
|
const [onsiteAcc, setOnsiteAcc] = useState<OnsiteReviewRequest[]>([]);
|
|
const [offsiteAcc, setOffsiteAcc] = useState<OffsiteReviewRequest[]>([]);
|
|
|
|
const onsiteQuery = trpc.blocks.listPendingRequests.useQuery(
|
|
{ limit: PAGE_LIMIT, cursor: onsiteCursor },
|
|
{ enabled, retry: false }
|
|
);
|
|
const offsiteQuery = trpc.appListings.listPendingRequests.useQuery(
|
|
{ limit: PAGE_LIMIT, cursor: offsiteCursor },
|
|
{ enabled, retry: false }
|
|
);
|
|
|
|
const onsitePage = (onsiteQuery.data?.items ?? []) as OnsiteReviewRequest[];
|
|
const offsitePage = (offsiteQuery.data?.items ?? []) as OffsiteReviewRequest[];
|
|
|
|
const onsiteItems = useMemo(
|
|
() => (onsiteCursor ? mergeById(onsiteAcc, onsitePage) : onsitePage),
|
|
[onsiteAcc, onsitePage, onsiteCursor]
|
|
);
|
|
const offsiteItems = useMemo(
|
|
() => (offsiteCursor ? mergeById(offsiteAcc, offsitePage) : offsitePage),
|
|
[offsiteAcc, offsitePage, offsiteCursor]
|
|
);
|
|
|
|
const onsiteNext = onsiteQuery.data?.nextCursor ?? null;
|
|
const offsiteNext = offsiteQuery.data?.nextCursor ?? null;
|
|
|
|
const resetPaging = useCallback(() => {
|
|
setOnsiteAcc([]);
|
|
setOffsiteAcc([]);
|
|
setOnsiteCursor(undefined);
|
|
setOffsiteCursor(undefined);
|
|
}, []);
|
|
|
|
const boundOnsite = useCallback(
|
|
// Register the tab's paging reset as the onsite modal's post-success callback so
|
|
// approving/rejecting an onsite item clears the accumulators + refetches page 1
|
|
// (mirrors the offsite path — kills the stale ghost-row after a decision).
|
|
(req: OnsiteReviewRequest) => openOnsiteReview(req, 'pending', resetPaging),
|
|
[openOnsiteReview, resetPaging]
|
|
);
|
|
const boundOffsite = useCallback(
|
|
(row: OffsitePendingRow) => openOffsiteReview(row, resetPaging),
|
|
[openOffsiteReview, resetPaging]
|
|
);
|
|
const boundCombined = useCallback(
|
|
(payload: CombinedReviewPayload) => openCombinedReview(payload, resetPaging),
|
|
[openCombinedReview, resetPaging]
|
|
);
|
|
|
|
const onLoadMore = () => {
|
|
if (onsiteNext != null) {
|
|
setOnsiteAcc(onsiteItems);
|
|
setOnsiteCursor(onsiteNext);
|
|
}
|
|
if (offsiteNext != null) {
|
|
setOffsiteAcc(offsiteItems);
|
|
setOffsiteCursor(offsiteNext);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<UnifiedReviewList
|
|
onsiteItems={onsiteItems}
|
|
offsiteItems={offsiteItems}
|
|
direction="asc"
|
|
openOnsiteReview={boundOnsite}
|
|
openOffsiteReview={boundOffsite}
|
|
openCombinedReview={boundCombined}
|
|
isLoading={onsiteQuery.isLoading || offsiteQuery.isLoading}
|
|
errorMessage={onsiteQuery.error?.message ?? offsiteQuery.error?.message}
|
|
emptyLabel="Queue is empty. Nothing waiting for review."
|
|
dateLabel="Submitted"
|
|
actionLabel="Review"
|
|
hasMore={onsiteNext != null || offsiteNext != null}
|
|
isLoadingMore={onsiteQuery.isFetching || offsiteQuery.isFetching}
|
|
onLoadMore={onLoadMore}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unified HISTORY tab (drives both Approved + Rejected) — newest-first, merging
|
|
// the on-site + off-site decided requests. Mirrors the pending tab's dual-source
|
|
// keyset pagination. All four history queries are declared (rules of hooks); only
|
|
// the active kind's pair is enabled.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function UnifiedHistoryTab({
|
|
kind,
|
|
openOnsiteReview,
|
|
openOffsiteReview,
|
|
}: {
|
|
kind: 'approved' | 'rejected';
|
|
openOnsiteReview: (
|
|
req: AnyRequest,
|
|
mode: OnsiteReviewMode,
|
|
onActioned?: () => void | Promise<void>
|
|
) => void;
|
|
openOffsiteReview: (
|
|
row: OffsitePendingRow,
|
|
onActioned?: () => void | Promise<void>,
|
|
readOnly?: boolean
|
|
) => void;
|
|
}) {
|
|
const features = useFeatureFlags();
|
|
const enabled = !!features?.appBlocks;
|
|
const isApproved = kind === 'approved';
|
|
|
|
const [onsiteCursor, setOnsiteCursor] = useState<string | undefined>(undefined);
|
|
const [offsiteCursor, setOffsiteCursor] = useState<string | undefined>(undefined);
|
|
const [onsiteAcc, setOnsiteAcc] = useState<OnsiteReviewRequest[]>([]);
|
|
const [offsiteAcc, setOffsiteAcc] = useState<OffsiteReviewRequest[]>([]);
|
|
|
|
const onsiteApprovedQ = trpc.blocks.listApprovedRequests.useQuery(
|
|
{ limit: PAGE_LIMIT, cursor: onsiteCursor },
|
|
{ enabled: enabled && isApproved, retry: false }
|
|
);
|
|
const onsiteRejectedQ = trpc.blocks.listRejectedRequests.useQuery(
|
|
{ limit: PAGE_LIMIT, cursor: onsiteCursor },
|
|
{ enabled: enabled && !isApproved, retry: false }
|
|
);
|
|
const offsiteApprovedQ = trpc.appListings.listApprovedRequests.useQuery(
|
|
{ limit: PAGE_LIMIT, cursor: offsiteCursor },
|
|
{ enabled: enabled && isApproved, retry: false }
|
|
);
|
|
const offsiteRejectedQ = trpc.appListings.listRejectedRequests.useQuery(
|
|
{ limit: PAGE_LIMIT, cursor: offsiteCursor },
|
|
{ enabled: enabled && !isApproved, retry: false }
|
|
);
|
|
|
|
const onsiteQuery = isApproved ? onsiteApprovedQ : onsiteRejectedQ;
|
|
const offsiteQuery = isApproved ? offsiteApprovedQ : offsiteRejectedQ;
|
|
|
|
const onsitePage = (onsiteQuery.data?.items ?? []) as OnsiteReviewRequest[];
|
|
const offsitePage = (offsiteQuery.data?.items ?? []) as OffsiteReviewRequest[];
|
|
|
|
const onsiteItems = useMemo(
|
|
() => (onsiteCursor ? mergeById(onsiteAcc, onsitePage) : onsitePage),
|
|
[onsiteAcc, onsitePage, onsiteCursor]
|
|
);
|
|
const offsiteItems = useMemo(
|
|
() => (offsiteCursor ? mergeById(offsiteAcc, offsitePage) : offsitePage),
|
|
[offsiteAcc, offsitePage, offsiteCursor]
|
|
);
|
|
|
|
const onsiteNext = onsiteQuery.data?.nextCursor ?? null;
|
|
const offsiteNext = offsiteQuery.data?.nextCursor ?? null;
|
|
|
|
const resetPaging = useCallback(() => {
|
|
setOnsiteAcc([]);
|
|
setOffsiteAcc([]);
|
|
setOnsiteCursor(undefined);
|
|
setOffsiteCursor(undefined);
|
|
}, []);
|
|
|
|
const boundOnsite = useCallback(
|
|
// History rows open in read-only mode ('approved'/'rejected'), so the action bar
|
|
// self-suppresses and onActioned never fires — but wire resetPaging for symmetry.
|
|
(req: OnsiteReviewRequest) => openOnsiteReview(req, kind, resetPaging),
|
|
[openOnsiteReview, kind, resetPaging]
|
|
);
|
|
const boundOffsite = useCallback(
|
|
// Off-site history opens the modal READ-ONLY (no Approve/Reject buttons) — an
|
|
// already-decided request would only error NOT_PENDING; this matches on-site.
|
|
(row: OffsitePendingRow) => openOffsiteReview(row, resetPaging, true),
|
|
[openOffsiteReview, resetPaging]
|
|
);
|
|
|
|
const onLoadMore = () => {
|
|
if (onsiteNext != null) {
|
|
setOnsiteAcc(onsiteItems);
|
|
setOnsiteCursor(onsiteNext);
|
|
}
|
|
if (offsiteNext != null) {
|
|
setOffsiteAcc(offsiteItems);
|
|
setOffsiteCursor(offsiteNext);
|
|
}
|
|
};
|
|
|
|
// APPROVED tab only — re-fire the build for an approved request whose build
|
|
// never started (deploy state null) or failed. The mutation takes ONLY the
|
|
// request id; the sha to rebuild is read server-side from the already-reviewed
|
|
// DB row. Tracks the in-flight id so only that row's button spins/disables.
|
|
const [retriggeringId, setRetriggeringId] = useState<string | null>(null);
|
|
const retriggerMutation = trpc.blocks.retriggerBuild.useMutation({
|
|
onSuccess: () => {
|
|
showSuccessNotification({
|
|
message: 'Build re-triggered — the deploy state will update as it progresses.',
|
|
});
|
|
resetPaging();
|
|
void onsiteApprovedQ.refetch();
|
|
},
|
|
onError: (e) =>
|
|
showErrorNotification({ title: 'Re-trigger failed', error: new Error(e.message) }),
|
|
onSettled: () => setRetriggeringId(null),
|
|
});
|
|
const onRetriggerBuild = useCallback(
|
|
(publishRequestId: string) => {
|
|
// Belt against a double-fire that slips past the button's own disabled state.
|
|
if (retriggerMutation.isPending) return;
|
|
setRetriggeringId(publishRequestId);
|
|
retriggerMutation.mutate({ publishRequestId });
|
|
},
|
|
[retriggerMutation]
|
|
);
|
|
|
|
return (
|
|
<UnifiedReviewList
|
|
onsiteItems={onsiteItems}
|
|
offsiteItems={offsiteItems}
|
|
direction="desc"
|
|
openOnsiteReview={boundOnsite}
|
|
openOffsiteReview={boundOffsite}
|
|
isLoading={onsiteQuery.isLoading || offsiteQuery.isLoading}
|
|
errorMessage={onsiteQuery.error?.message ?? offsiteQuery.error?.message}
|
|
emptyLabel={isApproved ? 'No approved requests yet.' : 'No rejected requests yet.'}
|
|
dateLabel="Reviewed"
|
|
actionLabel="View"
|
|
hasMore={onsiteNext != null || offsiteNext != null}
|
|
isLoadingMore={onsiteQuery.isFetching || offsiteQuery.isFetching}
|
|
onLoadMore={onLoadMore}
|
|
// Deploy column + retrigger control exist on the APPROVED tab only; the
|
|
// Rejected tab passes neither and renders exactly as before.
|
|
onRetriggerBuild={isApproved ? onRetriggerBuild : undefined}
|
|
retriggeringId={retriggeringId}
|
|
/>
|
|
);
|
|
}
|