mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
e2b27d8c78
* 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>