Files
civitai__civitai/designs
Manuel Emilio Urena e2b27d8c78 feat(challenges): Challenges Center — Your Challenges section + full-bleed section bands (#3241)
* design(challenges): add Challenges Center redesign Pencil canvas

V4 'Homepage Bands' direction (desktop MrENA, mobile BfCDo) plus earlier V1-V3 explorations. Referenced by the design spec + implementation plan in docs/superpowers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenge): add my-challenge result-state derivation

* feat(challenge): add getMyParticipated query + participated-card enrichment

Adds MyParticipatedChallengeItem schema types, a pure enrichParticipatedCards
mapper (kept in challenge-participation.util.ts, not challenge.service.ts, so
it stays unit-testable without importing the ~8K-line service module), and
the getMyParticipated DB query that hydrates a user's recently entered/won
challenges. Domain-currency and NSFW browsing-level gating mirror
getInfiniteChallenges's actual bitwise/buzzType clauses rather than a flat
nsfwLevel equality check.

* fix(challenge): narrow entry-image fixture type in enrich test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenge): expose challenge.getMyParticipated route

* feat(challenges-ui): add pure CTA/badge config for MyChallengeCard

TDD: getMyChallengeCta/getMyChallengeBadge derive the state-aware CTA
label/variant and result badge from MyChallengeResult, ahead of the
MyChallengeCard component that will consume them.

* feat(challenges-ui): add MyChallengeCard for participated challenges

Renders a participated-in challenge: entry-image cover via
AspectRatioImageCard (same props/badge tokens as ChallengeCard), a
result badge (won/placed/judging/entered), and a state-aware CTA
affordance. The CTA is a non-interactive styled div rather than a
Button — AspectRatioImageCard already makes the whole card a link to
the challenge, so a nested <button> would be an a11y violation.

Ladle story covers all 5 result states for Task 5's row consumer.

* feat(challenges-ui): add YourChallengesRow participated section

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenges-ui): add full-bleed SectionBand wrapper

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenges-ui): restructure challenges page into Challenges Center bands

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(challenges-ui): gate MyChallengeCard blur on the entry image's nsfwLevel

MyChallengeCard built the rendered image object using the challenge
aggregate nsfwLevel instead of the entry image's own nsfwLevel. The
entry image (myEntryImage) can carry a higher nsfwLevel than the
challenge aggregate, and ImageGuard's blur decision is the only
NSFW protection on non-green sites (no server-side browsing-level
gate applies there). Feeding it the wrong level let an NSFW own-entry
render unblurred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Update designs

* fix(challenges-ui): invalidate edit query after challenge upsert

The edit-page queries (getForEdit / getUserChallengeForEdit) inherit the
app-global staleTime: Infinity and were never invalidated on save, so
reopening the edit form served a stale cached challenge — themeElements
(and any other saved change) showed empty until a hard refresh. Invalidate
the respective edit query in each upsert onSuccess so the next edit open
refetches fresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenges-ui): match v4 design for Challenges Center sections

- Hide empty section bands and show per-section loading skeletons
  (new ChallengeCardSkeletonRow); each section owns its SectionBand so an
  empty/loading state removes the whole container.
- Fold the Daily header/info-modal into a self-contained
  DailyChallengesSection (replaces DailyChallengesRow); Featured and Your
  rows own their own bands too.
- Add design-matched section headers: leading icon + subtitle + chevron
  buttons (swords/grape Your, calendar/blue Daily, users/cyan Community).
- Redesign MyChallengeCard badges to the two-chip layout (result badge +
  dark status/time chip) with per-result colors.
- Add /challenges?engagement=participated list page behind the "See all"
  button; restyle "Daily Challenge Winners" to a subtle "Previous winners".
- Flush the first band under the subnav (-mt-3) and drop the alternating
  band background (SectionBand is now a bare positional div).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenges): event banner carousel, dedicated event pages + event-page redesign (#3272)

* feat(challenge-events): add coverImageId column to ChallengeEvent

Adds a nullable coverImageId FK on ChallengeEvent -> Image (relation
"ChallengeEventCover", ON DELETE SET NULL) to support cover-image
banners for Challenge Events. Migration SQL is manual-apply only per
repo convention; kysely/types.ts and models.ts also pick up unrelated
pre-existing schema drift (AppListing connect-scope fields,
BlockScopeInvocation external-oauth columns) that db:generate
regenerated in the same pass.

* feat(challenge-events): add coverImage + challengeEventId to schemas

* feat(challenge-events): enrich event cover image + add getChallengeEventById

Resolves the coverImage-missing typecheck errors introduced when
ChallengeEventListItem gained a coverImage field: getActiveEvents now
selects coverImageId and attaches the enriched ChallengeCoverImage via a
new shared getEventCoverImages helper (mirrors the existing per-challenge
cover mapping). Also adds getChallengeEventById for fetching a single
event's meta + cover + currently-visible challenge count.

* fix(challenge-events): stop coverImageId leak in getActiveEvents early-return

Destructure coverImageId out before spreading in the allChallenges===0
branch so it isn't leaked onto the ChallengeEventListItem response
(spreads bypass TS excess-property checks, so this was a runtime-only
shape mismatch vs. the main-return branch).

* feat(challenge-events): persist coverImageId on event upsert

* feat(challenge-events): filter infinite challenges by challengeEventId

Symmetric to the existing excludeEventChallenges gate — scopes the
infinite challenges query to a single event's challenges when
challengeEventId is set on the input.

* feat(challenge-events): add getEventById tRPC procedure

* feat(challenge-events): add EventBannerCard banner component

Full-width banner for a single challenge event: cover image (EdgeMedia)
with a gradient overlay derived from titleColor, title, date range,
visible-challenge count, and an Explore Event CTA. Whole card links to
/challenges/events/[id].

Gradient uses hardcoded hex (Tailwind's stock 900 shade per color name)
via arbitrary-value classes rather than `from-{color}-900` utilities,
since tailwind.config.js overrides blue/red/orange/yellow/green with
Mantine's 0-9 shade scale (no 900 shade exists for those keys here).

* feat(challenge-events): render events as banner carousel

Replace the Title+TwScrollX per-event listing with EventBannerCard,
wrapped in Embla when there's more than one visible event.

* feat(challenge-events): add dedicated event page with challenge grid

Adds /challenges/events/[id] with an optional trailing slug, gated on the
challengePlatform flag via createServerSideProps (matches challenges/index.tsx).
Hero uses getEventById for event metadata; the grid below uses ChallengesInfinite
filtered by challengeEventId.

EventBannerCard gains optional `linkable` and `count` props so the dedicated
event page can reuse it without self-linking or showing "0 challenges" (getEventById
always returns an empty challenges array since the grid loads separately).

* feat(challenge-events): add cover image upload to moderator event form

getChallengeEvents (mod list) didn't select/return coverImage, so it's
enriched via the existing getEventCoverImages helper to seed the edit form.

* fix(challenge-events): apply final review fixes for cover image upload

- events.tsx: make coverImage.id optional in the form schema so a
  freshly-uploaded cover (which has no DB id until server-side
  createImage) passes zodResolver validation — previously blocked
  setting a cover image through the moderator UI entirely
- [id]/[[...slug]].tsx: pass includeEnded: true to ChallengesInfinite
  so the grid matches the hero's challengeCount, which includes ended
  challenges
- migration.sql: wrap the column add + FK constraint in BEGIN/COMMIT
  to avoid a partial apply
- EventBannerCard.tsx: use alt="" on the decorative cover image since
  the adjacent Title already conveys the event name to screen readers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* design(challenges): add Event Detail page + mobile details modal frames

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenge-events): return active flag from getChallengeEventById

* feat(challenge-events): add EventHero component

Presentational hero for the event page (challenge count, active/ended
state, cover gradient). Renamed the back-link label from "All
Challenges" to "Back" since the original text's substring collided
with the pluralized "Challenges" count label under vitest-browser's
default (non-exact) getByText matching.

* fix(challenge-events): restore "All Challenges" back-link text in EventHero

Keep the design's actual back-link copy; disambiguate the test's
getByText query with { exact: true } instead of weakening the UI to
avoid a substring-match collision with the "Challenges" count label.

* feat(challenge-events): use EventHero on the event page

* refactor(challenge-events): drop unused EventBannerCard linkable/count props

FeaturedChallengeEvents is the sole caller and never passed either prop
(both were added for the old event-page hero, now replaced by EventHero).

* fix(challenge-events): correct EventHero ended-state label + heading order

- Gate the relative time text on the actual end date so a mod-deactivated
  event with a future end date no longer renders 'Event Ended · Ends in N days'.
- Bump the page 'Challenges' heading to h2 (hero is h1) to avoid an h1→h3 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(challenge-events): link event pages with title slug

Mirror the challenge detail page: EventBannerCard now links to
/challenges/events/{id}/{slug}, and the event page SSG-redirects
bare/incorrect-slug URLs to the canonical slug URL (same empty-slug
redirect-loop guard) plus a slug-based canonical meta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(challenges): restore alternating band background via CSS

Paint the alternating full-bleed band background on the parent
`.sections` container with nth-of-type(even), mirroring the home
page's home-block treatment, instead of a JS `tone` prop. Drops the
grape "your" tint; SectionBand is a bare positional div again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(challenges-ui): close review follow-ups on the Challenges Center

Verification pass over the Challenges Center work against its design spec
and implementation plans turned up a handful of loose ends:

- Your Challenges and Featured Events rendered a skeleton band while
  fetching. Most visitors have no entries and no active event, so that
  flashed a titled band at them and then removed it. Both now render
  nothing until they know there's something to show.
- The card's state CTA was a non-interactive <div>, justified by a comment
  claiming a nested link would be an a11y violation. It wouldn't:
  AspectRatioImageCard's card link wraps only the image, and the header and
  footer are siblings of it. The CTA is now a real link, focusable, and
  "View entry" points at the user's own image instead of the challenge.
- The meta chip read "Ended 3h ago" next to a Judging badge. No judging
  deadline is exposed, so it now states the phase instead.
- An unknown event id soft-404'd: the page returned props and let the
  client render NotFound, so the response was still a 200.
- The canonical title-slug redirect was duplicated across both challenge
  pages, including the empty-slug guard that keeps titles which slugify to
  '' from redirect-looping. Extracted to a shared, unit-tested helper.

Adds the branch coverage both plans called for and never got:
FeaturedChallengeEvents (0 / 1 / >1 events, loading, hidden-preferences)
and YourChallengesRow (logged out, loading, empty, populated).

Band background alternation stays as-is: the :nth-of-type approach is
intentional, not the per-band `tone` prop the plan sketched.

Verified in the running app: both redirects, the 404, the CTA targets, and
the mobile single-column collapse. EXPLAIN ANALYZE of getMyParticipated for
the heaviest participant (3398 entries) uses CollectionItem_addedById_idx
and lands at 203ms, so no new index is warranted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(challenges): gate user-created challenges behind the userChallenges flag

The flag covered the UI — the create button, the nav entry, the community
section, the create/edit pages — but not the data. A user without the flag who
had a direct link could open a user challenge and submit entries to it, which
is not what we want while this is still under test.

Two paths were open. The detail page's server-side props only checked
`challengePlatform`, so the page rendered for anyone. And entries are written
through the generic collection mutations (`bulkSaveItems`,
`addSimpleImagePost`), which are not challenge-specific and carried no guard at
all — `collection.service.ts` had no reference to `ChallengeSource` anywhere.

Gates are applied to the data rather than the views:

- `getChallengeDetail` returns null for a User-source challenge without the
  flag. The detail page's SSG prefetch and every client fetch resolve through
  it, and the page already falls through to NotFound on a null challenge.
- `getInfiniteChallenges` excludes them, so a hand-rolled `source: ['User']`
  request can't list what the feed hides.
- `validateContestCollectionEntry` rejects the entry. It is the one choke point
  every entry path shares. The lookup is scoped to `source: 'User'`, so
  ordinary contest collections and System/Mod daily challenges are untouched,
  and it runs before the entry fee is charged.
- `requestReview` refuses a user challenge, checked per challenge rather than
  on the procedure because paid review is shared with daily challenges.

The flag is per-user (availability 'mod' plus Flipt), so it is threaded from
`ctx.features.userChallenges` down through the controllers rather than
recomputed in the services — one source of truth. Everything defaults to
denied, so a caller that forgets to pass it fails closed.

10 new tests over the two gates. The existing contest-entry and detail-
visibility suites now pass the flag explicitly, since their fixtures are
User-source challenges and would otherwise stop at the new gate before
reaching the behaviour they cover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(challenges): address PR review — type layering + hidden-prefs gate

Two findings from review, both verified against the code first.

`MyChallengeResult` was defined in a service util that imports its other types
back from `challenge.schema.ts`, so schema and services each imported from the
other. It also meant the client-side card helpers reached into
`~/server/services/...` for a type. It is a bare string union with no
dependencies, so it moves to the schema, which is where the type that uses it
(`MyParticipatedChallengeItem`) already lives, and which the client already
imports from. Type-only either way, so nothing changes at runtime.

The second is a real inconsistency. `YourChallengesRow` filters through
`useApplyHiddenPreferences({ type: 'challenges' })`, whose gate reads
`coverImage.nsfwLevel` and drops any row without a cover at all — but the card
renders `myEntryImage`. So visibility was decided by one image while a
different one was displayed, and a challenge with no cover would be hidden even
when the user's own entry image was perfectly valid. `enrichParticipatedCards`
now points `coverImage` at the same image the card renders.

No content was being exposed by this: the card passes the entry image's own
nsfwLevel to ImageGuard2, so a spicier entry rendered blurred rather than
leaked. The bug was over-hiding and inconsistency, not disclosure. The
cover-less case is currently unreachable in production — 0 of 214 challenges
have a null coverImageId — so this is a latent fix rather than an active one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:28:56 -04:00
..