Files
civitai__civitai/src/components/Apps/MyAppsBody.tsx
T
Zachary Lowden 2f3c556c7b feat(apps): the manage/table routes SPEND the ultrawide width instead of padding it (#4619)
* 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.
2026-09-04 20:14:35 -05:00

1049 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Alert,
Badge,
Button,
Card,
Center,
Collapse,
Group,
Loader,
Pagination,
Paper,
Stack,
Table,
Text,
UnstyledButton,
} from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import {
IconAlertTriangle,
IconApps,
IconChevronDown,
IconChevronRight,
} from '@tabler/icons-react';
import Link from 'next/link';
import { useCallback, useMemo, useState } from 'react';
import { ListingProblemsIndicator } from '~/components/Apps/ListingProblemsIndicator';
import { showModRemovedNotice } from '~/components/Apps/listingPublishingActions';
import { AppListingScreenshotViewer } from '~/components/Apps/AppListingScreenshotViewer';
import {
NO_BROKEN_SCREENSHOTS,
withBrokenIndex,
type BrokenScreenshotIndexes,
} from '~/components/Apps/appListingScreenshotNav';
import type { MyAppMediaKind, MyAppRow } from '~/components/Apps/myAppsView';
import {
historyStatusColor,
listingMediaIndex,
listingMediaShots,
listingStatusColor,
myAppListingHref,
orphanGroupStartsOpen,
pageCount,
pageSlice,
partitionMyAppRows,
sortByRecentlyUpdated,
} from '~/components/Apps/myAppsView';
import { ownerListingState, ownerStateChip } from '~/components/Apps/offsiteOwnerControls';
import { AppsTableColgroup, APPS_MINE_COLUMNS } from '~/components/Apps/appsWideLayout';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { canOpenListingAuthoringPage } from '~/shared/constants/app-capabilities.constants';
import { formatDate } from '~/utils/date-helpers';
import { showErrorNotification, showSuccessNotification } from '~/utils/notifications';
import { trpc } from '~/utils/trpc';
/**
* `/apps/mine` — THE author's single view of every app they can act on.
*
* 🔴 THIS PAGE ABSORBED `/apps/my-submissions`, AND THE DIRECTION OF THE MERGE IS THE
* WHOLE POINT. That page listed PUBLISH REQUESTS scoped to `submittedByUserId`, which
* answers "what did I submit" — not "what do I own" and not "what do I hold a seat on". A
* collaborator has submitted nothing, so it was empty for them; an owner who acquired a
* listing by TRANSFER or by a moderator `claimListing` lost it there too, because the row
* keeps the original submitter's id forever. This page reads `appListings.listMine`
* (→ `listMyAppListings` → `resolveAccessibleListingIds`), which resolves ownership
* canonically and unions accepted seats. **The row set MUST stay that read.** Re-deriving
* it from a submissions query is the regression, and it is silent — the page still renders,
* just without the apps those two populations own.
*
* Row identity is one row per APP.
*
* 🔴 A ROW IS A LISTING AND ITS LINK — the History disclosure and the owner
* Unpublish/Republish pair BOTH MOVED to the canonical authoring page's History and
* Publishing tabs. Do not bring either back here: two homes for one control is how the two
* come to disagree, and a publish request is an EVENT on an app, which belongs on the app's
* own page rather than nested in a list row. What stays is the row's LINK, which now points
* at the tab that exists for that row (`myAppListingHref` → `editorTabsFor`), and the
* "Submissions without a listing" group — the one population with no listing and therefore
* no authoring page to move to.
*/
/** One row of `appListings.listMyOrphanedSubmissions` — a submission with no listing. */
export type OrphanedSubmissionRow = {
id: string;
slug: string;
version: string;
status: string;
submittedAt: string | Date;
reviewedAt: string | Date | null;
rejectionReason: string | null;
approvalNotes: string | null;
canWithdraw?: boolean;
};
/** Fixed media boxes. Both dimensions are attributes on the `img`, so the row reserves its
* space before the bytes arrive — a table with two images per row is otherwise a CLS
* machine. The placeholder uses the SAME box, so present and absent media never reflow. */
const ICON_BOX = 40;
const COVER_W = 96;
const COVER_H = 54; // 16:9
function formatWhen(value: string | Date | null | undefined): string {
if (!value) return '—';
return formatDate(value, 'MMM D, YYYY');
}
/**
* 🔴 THE CLICK TARGET IS A REAL `<button>`, AND THE PLACEHOLDER IS NOT ONE.
*
* Both media components below wrap their image in `UnstyledButton` — which renders a
* real `<button type="button">`, so it is tab-reachable, Enter/Space-activatable and
* carries a focus ring. An `<img onClick>` would be a mouse-only affordance that LOOKS
* wired up; the screenshot gallery this viewer is shared with learned that already
* (`appListingScreenshotViewerWiring.test.ts`'s "the tile is a real button").
*
* 🔴 NOT Mantine `Anchor`. Its root sets `color: var(--mantine-color-anchor)`, which
* recolours every `currentColor` descendant — including the "No cover" glyph inside the
* placeholder. That bug is INVISIBLE on the has-image path (an `<img>` ignores `color`)
* and only appears on the no-image path, which is the path that must not be a link at
* all. It is also not a navigation: nothing gets an href.
*
* 🔴 A PLACEHOLDER IS INERT — no button, no `tabIndex`, no pointer cursor. There is
* nothing to view, and a focusable control that opens an empty modal is worse than no
* control: it adds a tab stop to every row of a table whose rows are mostly incomplete
* listings (measured: all 11 `removed` listings have a null cover).
*/
function MediaButton({
label,
onOpen,
children,
}: {
label: string;
onOpen: () => void;
children: React.ReactNode;
}) {
return (
<UnstyledButton
onClick={onOpen}
aria-label={label}
// `display: flex` so the button box is exactly the image box — a default
// `display: block` UnstyledButton would add descender space under the image and
// make the focus ring taller than the thing it is outlining.
style={{ display: 'flex', cursor: 'zoom-in', borderRadius: 8 }}
>
{children}
</UnstyledButton>
);
}
function ListingIcon({
row,
onOpenMedia,
}: {
row: MyAppRow;
onOpenMedia?: (row: MyAppRow, which: MyAppMediaKind) => void;
}) {
if (!row.iconUrl) {
return (
<div
data-testid={`apps-mine-icon-placeholder-${row.appListingId}`}
aria-hidden
style={{
width: ICON_BOX,
height: ICON_BOX,
borderRadius: 8,
flex: `0 0 ${ICON_BOX}px`,
background: 'var(--mantine-color-dark-4)',
}}
/>
);
}
const img = (
// 🔴 A PLAIN `<img>`, NOT `next/image`. The server already hands us a CDN-transformed
// URL (`getEdgeUrl(..., { width })`), so `next/image` would put a SECOND optimizer in
// front of an already-optimized asset — extra cost, no smaller bytes. The two things
// `next/image` is usually reached for here are supplied directly: explicit
// `width`/`height` attributes reserve the box (no CLS on a table with two images per
// row) and `loading="lazy"` defers the off-screen ones.
// eslint-disable-next-line @next/next/no-img-element
<img
data-testid={`apps-mine-icon-${row.appListingId}`}
src={row.iconUrl}
alt=""
width={ICON_BOX}
height={ICON_BOX}
loading="lazy"
decoding="async"
style={{ borderRadius: 8, objectFit: 'cover', flex: `0 0 ${ICON_BOX}px` }}
/>
);
if (!onOpenMedia) return img;
return (
<MediaButton label={`View icon image for ${row.name}`} onOpen={() => onOpenMedia(row, 'icon')}>
{img}
</MediaButton>
);
}
function ListingCover({
row,
onOpenMedia,
}: {
row: MyAppRow;
onOpenMedia?: (row: MyAppRow, which: MyAppMediaKind) => void;
}) {
if (!row.coverUrl) {
return (
<div
data-testid={`apps-mine-cover-placeholder-${row.appListingId}`}
style={{
width: COVER_W,
height: COVER_H,
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--mantine-color-dark-5)',
}}
>
<Text size="9px" c="dimmed">
No cover
</Text>
</div>
);
}
const img = (
// Plain `<img>` for the same reason as the icon above — the URL is already a
// width-transformed CDN URL, and the CLS/lazy properties are set explicitly.
// eslint-disable-next-line @next/next/no-img-element
<img
data-testid={`apps-mine-cover-${row.appListingId}`}
src={row.coverUrl}
alt=""
width={COVER_W}
height={COVER_H}
loading="lazy"
decoding="async"
style={{ borderRadius: 6, objectFit: 'cover' }}
/>
);
if (!onOpenMedia) return img;
return (
<MediaButton
label={`View cover image for ${row.name}`}
onOpen={() => onOpenMedia(row, 'cover')}
>
{img}
</MediaButton>
);
}
/**
* The app's NAME — a link to the authoring page.
*
* 🔴 IT IS NOW A LINK ON EVERY ROW WHOSE STATUS THE ROUTE OPENS ON, INCLUDING A REMOVED OR
* REJECTED ONE, and that reversal is load-bearing rather than cosmetic. It used to be plain
* text on any non-authorable status because `getAppListingAuthoringContext` refused those
* with FORBIDDEN — linking there offered a guaranteed 403. That route now opens on them in
* a NARROWED mode (at most Publishing + History; no Details, no Collaborators), and this PR
* moved BOTH the History disclosure and the Unpublish/Republish pair off this row and into
* that page. So the link is the only way the author reaches either one, and leaving a
* REMOVED row unlinked would strand exactly the population that most needs its history.
*
* 🔴 `rejected` RIDES ALONG AS A FAIL-SAFE, NOT AS A SERVED POPULATION. Nothing writes
* `AppListing.status = 'rejected'` (measured across all 33 `appListing` write sites: an
* on-site reject deletes the draft listing, an off-site reject writes `removed`). The value
* is in the DB CHECK and legacy rows may carry it, so linking it is correct — but the
* rescue argument above is about `removed` alone. Rejected FIRST VERSIONS are served by the
* orphan group further down this page, which is deliberately untouched by this PR.
*
* 🔴 IT DELIBERATELY DOES **NOT** READ `role`, AND AN EARLIER DRAFT OF THIS FILE DID — on a
* premise that was simply false. That draft withheld the link from a seated EDITOR on a
* removed listing, justified as "the page would refuse them". It does not:
* `resolveListingAccess` returns `role:'editor'` for any accepted seat REGARDLESS of the
* listing's status, and `getAppListingAuthoringContext` refuses only on a missing role or a
* status the route does not open on — neither of which fires there. The server serves that
* editor a History-only page, exactly as `editorTabsFor` says it does. So the role clause
* was not a mirror of a server gate; it was an unannounced REGRESSION, because
* pre-PR the row's History toggle rendered unconditionally and a seated editor could open a
* removed app's history from here. Withholding the link now would leave them no route at
* all short of typing the URL.
*
* The predicate is therefore `canOpenListingAuthoringPage` itself, called directly rather
* than wrapped: one rule, one place, and no second name to drift from it.
*/
function ListingName({ row }: { row: MyAppRow }) {
if (canOpenListingAuthoringPage(row.status)) {
return (
<Link href={myAppListingHref(row)} data-testid={`apps-mine-link-${row.appListingId}`}>
<Text fw={600}>{row.name}</Text>
</Link>
);
}
return (
<Text fw={600} c="dimmed" data-testid={`apps-mine-unlinked-${row.appListingId}`}>
{row.name}
</Text>
);
}
/**
* 🔴 THERE IS NO KIND BADGE HERE, AND ITS ABSENCE IS THE DECISION.
*
* `/apps/mine` used to render one (testid `apps-mine-kind-<id>`). It is DELETED: the
* author already knows what they built, the word cost a badge slot in a row that also
* carries role, status and the completeness advisory, and the page is not where anyone
* looks the kind up. The kind still renders on the listing DETAIL page
* (`appListingDetailRows`' "Kind" row) and on the edit surface, which are the places
* that answer a question about it.
*
* 🔴 SO THIS FILE IS DELIBERATELY **NOT** ENROLLED in
* `__tests__/standaloneWordingCallSites.test.ts`. Enrolling it would assert that it
* resolves the kind word from the one source — a claim that is only meaningful for a
* surface that renders one. The absence is pinned instead, by
* `MyAppsBody.browser.test.tsx`'s "no row shape renders a kind badge", so a future
* "helpfully restore the badge" change is visible rather than silent.
*/
function StatusBadges({ row }: { row: MyAppRow }) {
return (
<Group gap={6} wrap="nowrap">
{/* 🔴 The role badge is not decoration: an editor cannot invite, remove or transfer,
so saying which one they are is what makes the missing controls legible rather
than looking broken. */}
<Badge
variant="filled"
color={row.role === 'owner' ? 'teal' : 'indigo'}
data-testid={`apps-mine-role-${row.appListingId}`}
>
{row.role === 'owner' ? 'Owner' : 'Collaborator'}
</Badge>
{/*
🔴 A REMOVED LISTING'S BADGE SAYS *WHO* REMOVED IT. `status` reads `removed` for an
owner self-unpublish and for a moderator takedown alike, which is precisely the
distinction the author needs — one of those they can undo themselves and the other
they cannot. `ownerStateChip` returns null for every other state, so the normal
status badge is unchanged there. Same override the off-site and on-site submissions
lists apply, from the same function.
*/}
{(() => {
const chip = ownerStateChip(
ownerListingState({
listingStatus: row.status,
lastModerationAction: row.lastModerationAction,
})
);
return (
<Badge
variant="outline"
color={chip ? chip.color : listingStatusColor(row.status)}
data-testid={`apps-mine-status-${row.appListingId}`}
>
{chip ? chip.label : row.status}
</Badge>
);
})()}
{/*
🔴 THE COMPLETENESS ADVISORY'S ONLY REMAINING HOME. It hung off the two
`/apps/my-submissions` tables, which lost their importer when that page merged
here — so without this the author stops being told that the icon, cover,
screenshots, description, tagline or category are missing. It is also what makes
`listingCoverUrl`'s "no screenshot fallback, the author must see the gap"
rationale true rather than merely asserted: that comment cites this warning.
*/}
<span data-testid={`apps-mine-problems-${row.appListingId}`}>
<ListingProblemsIndicator problems={row.problems ?? []} />
</span>
</Group>
);
}
/**
* "Removed by a moderator" — a STATEMENT, not an action, which is why it is not in the
* ledger's action set and why it renders for a collaborator too.
*
* 🔴 IT EXISTS SO THE MISSING BUTTON IS LEGIBLE. An owner-unpublished row and a
* moderator-removed row differ only by the presence of Republish; without this line the
* second one is an empty cell, and an empty cell is what a dropped control looks like.
*/
function ModRemovedNotice({ row }: { row: MyAppRow }) {
if (!showModRemovedNotice(row)) return null;
return (
/*
* 🔴 IT STATES THE CONSEQUENCE, NOT A CAUSE, AND THE DIFFERENCE IS TRUTH. An earlier
* wording read "Removed by a moderator — contact them to restore it", which asserts WHO
* removed the app. That is not what this state means: the server's guard refuses an owner
* republish whenever the newest moderation event is anything other than `owner-unpublish`,
* and `resolveReport`/`dismissReport` write event rows too. So an owner who unpublishes
* their own app and then has a pre-existing report closed by a moderator lands here — the
* refusal is real and the mirror is faithful, but nobody removed their app. The sentence
* now says only the part that is true in every case that reaches it.
*
* (The `removed by a moderator` BADGE has the same problem and is deliberately untouched:
* it comes from the shared `ownerStateChip`, which the off-site and on-site submissions
* lists also render. Rewording it is a cross-surface copy change, not this PR's.)
*/
<Text size="xs" c="red" data-testid={`apps-mine-mod-removed-${row.appListingId}`}>
Only a moderator can restore this listing.
</Text>
);
}
/**
* 🔴 A ROW IS NOW A LISTING AND ITS LINK — NOTHING ELSE. The History disclosure and the
* Unpublish/Republish pair both moved to `/apps/listing/<id>/edit`, so a row carries no
* per-row query, no expansion state and no mutation. That is why `expanded`/`onToggle`/
* `history*`/`onWithdraw`/`onUnpublish`/`onRepublish` are gone from this type rather than
* being threaded through unused.
*/
type RowRenderProps = {
row: MyAppRow;
group: 'active' | 'inactive';
/** Open the row's image viewer at the image that was clicked. */
onOpenMedia?: (row: MyAppRow, which: MyAppMediaKind) => void;
};
function rowTestId(group: 'active' | 'inactive', appListingId: string): string {
return group === 'active'
? `apps-mine-row-${appListingId}`
: `apps-mine-inactive-row-${appListingId}`;
}
/** Desktop: one `<tr>`. */
function AppTableRow(props: RowRenderProps) {
const { row, group } = props;
return (
<>
<Table.Tr data-testid={rowTestId(group, row.appListingId)}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<ListingIcon row={row} onOpenMedia={props.onOpenMedia} />
<Stack gap={0}>
<ListingName row={row} />
<Text size="xs" c="dimmed">
{row.slug}
</Text>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<ListingCover row={row} onOpenMedia={props.onOpenMedia} />
</Table.Td>
<Table.Td>
<Stack gap={4} align="flex-start">
<StatusBadges row={row} />
<ModRemovedNotice row={row} />
</Stack>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{formatWhen(row.updatedAt)}
</Text>
</Table.Td>
</Table.Tr>
</>
);
}
/**
* Mobile: the SAME row as a card.
*
* 🔴 A CARD, NOT A HORIZONTALLY-SCROLLING TABLE. Two images per row makes the table's
* natural width far wider than a phone, and the alternative to reflowing is a side-scroll
* that hides the status and the controls off-screen. Exactly ONE of the two layouts is
* rendered (the container picks with `useMediaQuery`, the View takes it as a prop), so
* there is never a duplicate copy of a row in the DOM.
*/
function AppCardRow(props: RowRenderProps) {
const { row, group } = props;
return (
<Paper withBorder p="sm" radius="md" data-testid={rowTestId(group, row.appListingId)}>
<Stack gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<ListingIcon row={row} onOpenMedia={props.onOpenMedia} />
<Stack gap={0} style={{ minWidth: 0, flex: 1 }}>
<ListingName row={row} />
<Text size="xs" c="dimmed">
{row.slug}
</Text>
</Stack>
<ListingCover row={row} onOpenMedia={props.onOpenMedia} />
</Group>
<StatusBadges row={row} />
<ModRemovedNotice row={row} />
<Text size="xs" c="dimmed">
Updated {formatWhen(row.updatedAt)}
</Text>
</Stack>
</Paper>
);
}
function AppGroup({
rows,
group,
compact,
testId,
renderRow,
}: {
rows: MyAppRow[];
group: 'active' | 'inactive';
compact: boolean;
testId: string;
renderRow: (row: MyAppRow, group: 'active' | 'inactive') => RowRenderProps;
}) {
if (compact) {
return (
<Stack gap="xs" data-testid={testId}>
{rows.map((row) => (
<AppCardRow key={row.appListingId} {...renderRow(row, group)} />
))}
</Stack>
);
}
return (
<Card withBorder p={0} data-testid={testId}>
<Table verticalSpacing="sm" horizontalSpacing="md">
{/*
🔴 FIRST CHILD, BEFORE the row groups — HTML requires it there; the ordering is
pinned by `__tests__/appsWideLayout.test.ts`. `App` carries the icon, the name and
the slug and is the ledger's primary column, so the container's surplus width
lands there instead of being distributed as padding across four columns.
*/}
<AppsTableColgroup columns={APPS_MINE_COLUMNS} />
<Table.Thead>
<Table.Tr>
<Table.Th data-testid="apps-mine-col-app">App</Table.Th>
<Table.Th>Cover</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Updated</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<AppTableRow key={row.appListingId} {...renderRow(row, group)} />
))}
</Table.Tbody>
</Table>
</Card>
);
}
export type MyAppsBodyViewProps = {
rows: MyAppRow[];
isLoading?: boolean;
errorMessage?: string | null;
/** Render the card layout. Injected rather than measured so tests are deterministic. */
compact?: boolean;
/**
* Is the VERSION-withdraw mutation reachable for this viewer? Defaults to `true`; the
* container passes `features.appBlocks`, because `blocks.withdrawPublishRequest` carries
* `enforceAppBlocksFlag` while this page does not.
*
* 🔴 IT SURVIVES THE HISTORY MOVE BECAUSE THE ORPHAN GROUP STILL NEEDS IT. Every other
* withdraw affordance on this page went with the History panel; the orphaned-submission
* rows are submission-keyed, have no listing and therefore no authoring page, so their
* Withdraw button stays here — and it is a BLOCK publish request, i.e. exactly the half
* this flag gates.
*/
withdrawEnabled?: boolean;
/** Is an orphan withdraw in flight? Disables the button rather than double-firing. */
withdrawing?: boolean;
/** Submissions whose listing was deleted — see `listMyOrphanedSubmissions`. */
orphanedSubmissions?: OrphanedSubmissionRow[];
/** Message from a FAILED orphan read. Never conflate with an empty one. */
orphanedError?: string | null;
/** Is the orphan read still in flight? An empty result mid-stream is not an empty set. */
orphanedLoading?: boolean;
onWithdrawOrphan?: (row: OrphanedSubmissionRow) => void;
};
export function MyAppsBodyView({
rows,
isLoading = false,
errorMessage = null,
compact = false,
withdrawEnabled = true,
withdrawing = false,
orphanedSubmissions = [],
orphanedError = null,
orphanedLoading = false,
onWithdrawOrphan,
}: MyAppsBodyViewProps) {
const [inactiveOpen, setInactiveOpen] = useState(false);
const [inactivePage, setInactivePage] = useState(1);
/**
* The row whose images are open in the viewer, and which one is on screen.
*
* 🔴 ONE VIEWER FOR THE WHOLE PAGE, not one per row. A `<Modal>` per row would put
* `rows.length` dialogs in the DOM, each registering its own capture-phase `keydown`
* listener for Escape — and this table routinely renders dozens of rows.
*
* 🔴 `broken` IS RESET ON EVERY OPEN, because it is a set of indices into THIS row's
* `[cover, icon]` list. Carrying it across rows would mean "index 1 is broken" —
* learned from one listing's icon — silently hiding a different listing's icon. That
* is the same index-space rule the screenshot gallery states in
* `appListingScreenshotNav.ts`; here the list changes per row rather than per refetch.
*/
const [mediaTarget, setMediaTarget] = useState<{ rowId: string; index: number } | null>(null);
const [mediaBroken, setMediaBroken] = useState<BrokenScreenshotIndexes>(NO_BROKEN_SCREENSHOTS);
const openMedia = useCallback((row: MyAppRow, which: MyAppMediaKind) => {
const index = listingMediaIndex(row, which);
// 🔴 `null` means that image is absent. Unreachable from the UI today (a placeholder
// renders no button at all), so this is the structural half of that guarantee rather
// than its only enforcement — an opened-on-nothing viewer is an empty modal.
if (index === null) return;
setMediaBroken(NO_BROKEN_SCREENSHOTS);
setMediaTarget({ rowId: row.appListingId, index });
}, []);
const closeMedia = useCallback(() => setMediaTarget(null), []);
const markMediaBroken = useCallback(
(index: number) => setMediaBroken((prev) => withBrokenIndex(prev, index)),
[]
);
const { active, inactive } = useMemo(
() => partitionMyAppRows(sortByRecentlyUpdated(rows)),
[rows]
);
const inactivePages = pageCount(inactive.length);
const inactiveVisible = pageSlice(inactive, inactivePage);
// Resolved from `rows` rather than stored on open, so the viewer can never outlive
// the row it is showing (see the mount site).
const mediaRow = mediaTarget
? rows.find((r) => r.appListingId === mediaTarget.rowId) ?? null
: null;
const renderRow = useCallback(
(row: MyAppRow, group: 'active' | 'inactive'): RowRenderProps => ({
row,
group,
onOpenMedia: openMedia,
}),
[openMedia]
);
/**
* 🔴 A FAILED `listMine` MUST NOT SWALLOW THE ORPHAN GROUP. This used to `return` the
* alert, which meant one failing read blanked the ONE surface a rejected first
* submission is reachable from — an invisible population arriving by a different route,
* i.e. the same failure mode as the defect this page exists to fix. The alert now
* renders BESIDE whatever else resolved.
*
* 🔴 AND THE GUARD KEYS ON THE ORPHAN READ'S *ERROR* AS WELL AS ITS DATA. Keying on
* `orphanedSubmissions.length` alone reopened the same hole one door along: with BOTH
* reads failed and zero orphan rows, the early return fired and the orphan failure
* reported nothing, permanently. Zero rows is not the same fact as "the read succeeded
* and found none" — that is the whole silent-zero lesson, applied to the guard itself.
*/
const rowsFailed = !!errorMessage;
if (rowsFailed && orphanedSubmissions.length === 0 && !orphanedError) {
return (
<Alert
color="red"
variant="light"
icon={<IconAlertTriangle size={16} />}
data-testid="apps-mine-error"
>
{errorMessage}
</Alert>
);
}
/**
* 🔴 WHAT THIS GUARD ACTUALLY DOES, stated because its first comment described only the
* error case while its real effect is here: once the ORPHAN read resolves while `rows`
* are still loading, the loader stops rendering and the rows area would be empty with no
* loading affordance. Falling through is correct — the orphan group is real content and
* showing it beats a spinner over data that already arrived — but the page must then not
* claim the account is empty, which is what `orphanedLoading` below is for.
*/
if (isLoading && orphanedSubmissions.length === 0) {
return (
<Center py="xl">
<Loader />
</Center>
);
}
/**
* 🔴 THE EMPTY STATE IS NOT AN EARLY RETURN, and that distinction is a defect this
* suite caught. An account whose ONLY records are orphaned submissions has zero
* listings, so returning here would render "you don't own any apps yet" over the top of
* the one group that finally makes their rejected first version visible — the original
* defect, reappearing one layer up. The alert renders INSIDE the stack, beside them.
*/
/**
* 🔴 `orphanedLoading` IS PART OF THIS PREDICATE, and it is reachable rather than
* defensive. The two procedures batch into ONE request under `httpBatchStreamLink` but
* stream back INDEPENDENTLY, so `rowsQuery` resolving empty while the orphan read is
* still in flight is an ordinary interleaving — and without this term the page renders
* "You don't own or collaborate on any apps yet" over a pending read. Streaming makes
* that MORE reachable, not less.
*/
const hasNothingAtAll =
rows.length === 0 &&
orphanedSubmissions.length === 0 &&
!rowsFailed &&
!orphanedError &&
!orphanedLoading;
return (
<Stack gap="lg" data-testid="apps-mine-list">
{rowsFailed && (
<Alert
color="red"
variant="light"
icon={<IconAlertTriangle size={16} />}
data-testid="apps-mine-error"
>
{errorMessage}
</Alert>
)}
{/*
🔴 A FAILING ORPHAN READ MUST SAY SO. `orphansQuery.error` was read NOWHERE, so a
failure rendered nothing and reported nothing — indistinguishable from "you have no
rejected submissions", which is the exact lie this group was added to stop telling.
A reassuring empty result is not evidence of an empty set.
*/}
{orphanedError && (
<Alert
color="red"
variant="light"
icon={<IconAlertTriangle size={16} />}
data-testid="apps-mine-orphaned-error"
>
{orphanedError}
</Alert>
)}
{hasNothingAtAll && (
<Alert
color="gray"
variant="light"
icon={<IconApps size={16} />}
data-testid="apps-mine-empty"
>
You dont own or collaborate on any apps yet.
</Alert>
)}
{rows.length === 0 ? null : active.length === 0 ? (
<Alert color="gray" variant="light" data-testid="apps-mine-active-empty">
Every app you can access is inactive. Open Inactive below to see them.
</Alert>
) : (
<AppGroup
rows={active}
group="active"
compact={compact}
testId="apps-mine-table"
renderRow={renderRow}
/>
)}
{inactive.length > 0 && (
<Stack gap="xs">
{/*
🔴 COLLAPSED BY DEFAULT, WITH THE COUNT IN THE HEADER. A count on a closed
control is what makes it worth opening; without it the section is an
unlabelled box and the removed/rejected apps are simply gone as far as the
owner can tell.
*/}
<UnstyledButton
onClick={() => setInactiveOpen((v) => !v)}
aria-expanded={inactiveOpen}
aria-controls="apps-mine-inactive-panel"
data-testid="apps-mine-inactive-toggle"
>
<Group gap={6}>
{inactiveOpen ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
<Text fw={600} size="sm">
Inactive
</Text>
<Badge variant="light" color="gray" data-testid="apps-mine-inactive-count">
{inactive.length}
</Badge>
</Group>
</UnstyledButton>
<Collapse in={inactiveOpen}>
<Stack gap="xs" id="apps-mine-inactive-panel" data-testid="apps-mine-inactive-panel">
<AppGroup
rows={inactiveVisible}
group="inactive"
compact={compact}
testId="apps-mine-inactive-table"
renderRow={renderRow}
/>
{inactivePages > 1 && (
<Group justify="center" data-testid="apps-mine-inactive-pagination">
<Pagination
total={inactivePages}
value={Math.min(inactivePage, inactivePages)}
onChange={setInactivePage}
size="sm"
/>
</Group>
)}
</Stack>
</Collapse>
</Stack>
)}
{orphanedSubmissions.length > 0 && (
<OrphanedSubmissionsSection
rows={orphanedSubmissions}
onWithdraw={onWithdrawOrphan}
withdrawing={withdrawing}
withdrawEnabled={withdrawEnabled}
/>
)}
{/*
🔴 REUSED, NOT REBUILT — and the alternatives were rejected for a STRUCTURAL
reason, not by omission. `ImageViewer` and `ImageDetailModal` are keyed on a
numeric civitai `Image` id and driven by a `?imageId=` query param; a row here
carries a CDN URL STRING (`getEdgeUrl`) and no image id exists to hand them.
`AppListingScreenshotViewer` is URL-keyed, which is exactly the shape this page
has — see its own rejected-alternatives ledger for the longer version.
It is mounted OUTSIDE the row list so it survives pagination and the Inactive
collapse closing under it; `mediaRow` is looked up from `rows`, so a row that
leaves the list (a withdraw, a refetch) leaves `shots` empty and the viewer's
own rescue effect closes it rather than framing a dead URL.
Its `stackId` is INERT here — degrading cleanly with no `Modal.Stack` ancestor
is a documented property of that component, and `/apps/mine` nests no dialogs.
*/}
<AppListingScreenshotViewer
shots={mediaRow ? listingMediaShots(mediaRow) : []}
name={mediaRow?.name ?? ''}
broken={mediaBroken}
index={mediaRow ? mediaTarget?.index ?? null : null}
onIndexChange={(index) => setMediaTarget((prev) => (prev ? { ...prev, index } : prev))}
onBroken={markMediaBroken}
onClose={closeMedia}
/>
</Stack>
);
}
/**
* Submissions whose LISTING NO LONGER EXISTS — the population no app-keyed row can show.
*
* 🔴 SUBMISSION-KEYED, NOT APP-KEYED, and that is forced rather than chosen. A first
* version that is rejected or withdrawn has its pre-approval draft listing DELETED to
* release the slug, so there is no app to nest this under. Measured on production
* 2026-08-20: **3 of 3 rejected** and **27 of 33 withdrawn** on-site requests are in that
* state — i.e. 100% of rejections were unreachable from anywhere in the product, including
* from the "your app was rejected" notification that now points at this page.
*
* 🔴 COLLAPSIBLE, BUT IT OPENS ITSELF WHENEVER IT HAS SOMETHING ACTIONABLE — and that
* conditional is what carries the old "ALWAYS VISIBLE" guarantee forward rather than
* dropping it. The previous rule was that this group must never sit behind a toggle,
* for three reasons that all still hold: it is the only surface in the product showing
* a REJECTION REASON, the "your app was rejected" notification deep-links to this page,
* and on production 2026-08-20 **3 of 3 rejected** and **27 of 33 withdrawn** on-site
* requests were unreachable from anywhere before it existed.
*
* Read those three reasons precisely: every one of them is about a row the author can
* still ACT ON. None of them is an argument for keeping an unbounded pile of settled
* history permanently expanded above the fold. So the rule is now:
*
* - a row with a rejection reason, or a `pending` row the SERVER says this caller may
* withdraw → the group is open on arrival, with no interaction. The notification
* deep-link lands on an open group, which is the guarantee that mattered;
* - nothing actionable → collapsed, because it is archive.
*
* 🔴 THE COUNT BADGE STAYS ON THE HEADER, closed or open. A collapsed group with no
* count is an unlabelled box, which is indistinguishable from the rows being GONE — the
* exact impression this whole section exists to stop giving. The predicate itself is
* `orphanGroupStartsOpen` in `myAppsView.ts`, in the blocking `unit` project.
*
* 🔴 STILL NOT INSIDE THE INACTIVE COLLAPSE. That has not changed and is not the same
* question: this is its OWN disclosure, whose open state it decides from its OWN rows.
* Nesting it under Inactive would put an actionable rejection two clicks deep behind a
* control that says nothing about it.
*/
function OrphanedSubmissionsSection({
rows,
onWithdraw,
withdrawing,
withdrawEnabled,
}: {
rows: OrphanedSubmissionRow[];
onWithdraw?: (row: OrphanedSubmissionRow) => void;
withdrawing: boolean;
withdrawEnabled: boolean;
}) {
/**
* Initial state ONLY — deliberately not re-derived on every `rows` change. Once the
* author has opened or closed this group, a refetch (or a withdraw removing the one
* actionable row) must not reach in and move it under their hands.
*/
const [open, setOpen] = useState(() => orphanGroupStartsOpen(rows));
return (
<Stack gap="xs" data-testid="apps-mine-orphaned">
<UnstyledButton
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls="apps-mine-orphaned-panel"
data-testid="apps-mine-orphaned-toggle"
>
<Group gap={6}>
{open ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
<Text fw={600} size="sm">
Submissions without a listing
</Text>
<Badge variant="light" color="gray" data-testid="apps-mine-orphaned-count">
{rows.length}
</Badge>
</Group>
</UnstyledButton>
<Collapse in={open}>
<Stack gap="xs" id="apps-mine-orphaned-panel" data-testid="apps-mine-orphaned-panel">
<Text size="xs" c="dimmed">
These apps never got a store listing a first version that was rejected or withdrawn
releases its slug, so there is no app page to show them on.
</Text>
<Stack gap="xs">
{rows.map((r) => (
<Paper
key={r.id}
withBorder
p="sm"
radius="md"
data-testid={`apps-mine-orphaned-row-${r.id}`}
>
<Group justify="space-between" wrap="wrap" gap="xs">
<Stack gap={2}>
<Text fw={600}>{r.slug}</Text>
<Text size="xs" c="dimmed">
v{r.version} · submitted {formatWhen(r.submittedAt)}
</Text>
</Stack>
<Group gap="xs">
<Badge
variant="outline"
color={historyStatusColor(r.status)}
data-testid={`apps-mine-orphaned-status-${r.id}`}
>
{r.status}
</Badge>
{r.canWithdraw && onWithdraw && withdrawEnabled ? (
<Button
size="compact-xs"
variant="subtle"
color="gray"
disabled={withdrawing}
onClick={() => onWithdraw(r)}
data-testid={`apps-mine-orphaned-withdraw-${r.id}`}
>
Withdraw
</Button>
) : null}
</Group>
</Group>
{/* 🔴 THE REVIEWER'S REASON IS THE POINT OF THIS GROUP. It is the only thing
on the whole record that tells the developer what to change, and it was
unreachable before this section existed. */}
{r.rejectionReason ? (
<Text size="xs" c="red" mt={6} data-testid={`apps-mine-orphaned-notes-${r.id}`}>
{r.rejectionReason}
</Text>
) : r.approvalNotes ? (
<Text
size="xs"
c="dimmed"
mt={6}
data-testid={`apps-mine-orphaned-notes-${r.id}`}
>
{r.approvalNotes}
</Text>
) : null}
</Paper>
))}
</Stack>
</Stack>
</Collapse>
</Stack>
);
}
/**
* The container.
*
* 🔴 TWO READS, NO MUTATIONS EXCEPT THE ORPHAN WITHDRAW. The lazy per-row `listingHistory`
* query, the `unpublishOwnListing` modal and the `republishOwnListing` mutation all moved
* to the authoring page's Publishing / History tabs. What is left here is the row list and
* the orphan group — the one population with no listing, and therefore no authoring page to
* move to.
*/
export function MyAppsBody() {
// 🔴 `48em` is Mantine's `sm` breakpoint. `useMediaQuery` returns `undefined` before it
// has measured, so the `=== true` keeps the first paint on the table rather than
// flashing the card layout on desktop.
const isCompact = useMediaQuery('(max-width: 48em)') === true;
/**
* 🔴 READ FOR THE *WRITE*, NOT FOR THE PAGE. The page and both of its reads gate on
* `appBlocksAuthor` ONLY — deliberately, because `appBlocks` is store VISIBILITY and an
* author must be able to see their own apps when the store narrows. But
* `blocks.withdrawPublishRequest` carries `.use(enforceAppBlocksFlag)`, so with
* `appBlocksAuthor` on and `appBlocks` off the page renders and the orphan Withdraw 403s.
*/
const features = useFeatureFlags();
const rowsQuery = trpc.appListings.listMine.useQuery(undefined, { retry: false });
/**
* Submissions whose listing was deleted. One flat, bounded read alongside the rows —
* this group IS the only surface these records have, so it is not lazy.
*/
const orphansQuery = trpc.appListings.listMyOrphanedSubmissions.useQuery(undefined, {
retry: false,
});
const utils = trpc.useUtils();
const withdrawVersion = trpc.blocks.withdrawPublishRequest.useMutation({
onSuccess: () => {
showSuccessNotification({ message: 'Submission withdrawn.' });
void utils.appListings.listMine.invalidate();
void utils.appListings.listMyOrphanedSubmissions.invalidate();
},
onError: (e) =>
showErrorNotification({ title: 'Withdraw failed', error: new Error(e.message) }),
});
// An orphan is by construction a BLOCK publish request, so it only ever has one proc.
const onWithdrawOrphan = useCallback(
(rowToWithdraw: OrphanedSubmissionRow) => {
withdrawVersion.mutate({ publishRequestId: rowToWithdraw.id });
},
[withdrawVersion]
);
return (
<MyAppsBodyView
rows={(rowsQuery.data ?? []) as MyAppRow[]}
isLoading={rowsQuery.isLoading}
errorMessage={rowsQuery.error?.message ?? null}
compact={isCompact}
orphanedSubmissions={(orphansQuery.data ?? []) as OrphanedSubmissionRow[]}
orphanedError={orphansQuery.error?.message ?? null}
orphanedLoading={orphansQuery.isLoading}
onWithdrawOrphan={onWithdrawOrphan}
withdrawing={withdrawVersion.isPending}
withdrawEnabled={!!features?.appBlocks}
/>
);
}