mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
fix(ads): stop requesting ads on NSFW-gated pages
Gated pages on civitai.com were still running an auction behind the
"content has moved to civitai.red" card. Measured logged-out: 1 GAM
request for the adhesive unit plus ~1145 prebid/SSP calls, carrying the
gated URL as page_url. That is what lands NSFW URLs in GAM's policy
violation center — ~200 pages are currently flagged and serving no or
low-CPM ads.
Gated already suppressed side_1/side_2/incontent, which live inside its
children. The leak was the adhesive footer: it renders from AppLayout
outside {children}, below AdsProvider in the tree, and so is unreachable
by the gate.
Suppression keys off the content rating, not the gate verdict —
civitai.com serves PG and PG13, so a PG13 page stays monetized even while
an anonymous viewer sees a login gate. isAdGatedContent is also
viewer-independent: one auction from an owner, mod, or crawler is enough
to put a URL in the policy center.
Applied at the AdsProvider context boundary so every ad unit inherits it,
while the Snigel loader and the .red adblock probe keep using the ungated
local — the loader is inert without a slot and drives the CMP handshake
that sets ready. Never applies to .red, which serves direct ads with no
GAM auction.
SSR is the load-bearing half: AdUnitRenderable renders during SSR, so a
gated page ships a reserved-height ad slot that paints before hydration.
Pages declare gating in getServerSideProps; createServerSideProps
resolves it into the adsGated prop _app hands to AdsProvider. useAdGate
covers client-side navigation, where a layout effect lands before paint.
3D models gained useSSG plus a model3d fetch; it previously SSR'd a
loader with no Gated mounted.
Not verifiable locally (isDev forces adsEnabled false) — run
scripts/ad-request-check.mjs against a preview deploy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
# Plan: let `Gated` suppress ads on civitai.com
|
||||
|
||||
Two workstreams from the ad provider's 2026-07 report of ~200 policy-flagged pages:
|
||||
|
||||
1. **Stop gated pages requesting ads** — pages already hidden behind the `.red` gate that
|
||||
still fire an auction. Implemented; everything up to "Deferred" below.
|
||||
2. **Audit model names and page content for ad-unsafe text** — pages that are SFW by *our*
|
||||
rules but still trip GAM's classifier. See "Workstream 2" near the end.
|
||||
|
||||
## Problem
|
||||
|
||||
Gated NSFW pages on `.com` still make an ad request behind the "This content has a new
|
||||
home" card. Measured headless + logged out on 2026-07-27:
|
||||
|
||||
| | `/models/1972981/sex-nudes-...` (gated) | `/models/1166008` (control) |
|
||||
| --- | --- | --- |
|
||||
| GAM ad requests (`gampad/ads`) | **1** | 1 |
|
||||
| ad units requested | **`adhesive`** | `side_1`, `side_2`, `adhesive` |
|
||||
| prebid / SSP auction calls | **1145** | 1443 |
|
||||
| rendered ad iframes | **1** | 0 |
|
||||
|
||||
The request carries `page_url` = the gated `.com` URL. That is what puts the URL into GAM's
|
||||
inventory and therefore into the policy violation center — our provider reports ~200 flagged
|
||||
pages currently serving no ads or very low CPMs.
|
||||
|
||||
## Scope is narrower than it first looks
|
||||
|
||||
**`Gated` already does its job for in-page units.** `side_1`, `side_2`, and `incontent_*`
|
||||
live inside `{children}` ([`Gated.tsx:218`](../src/components/Gated/Gated.tsx#L218) only
|
||||
renders children for `state === 'page'`), so on a gated page they are never defined as GPT
|
||||
slots and never auctioned. The control page defines three slots; the gated page defines one.
|
||||
|
||||
The sole leak is **`adhesive`**, rendered from
|
||||
[`AppLayout.tsx:95`](../src/components/AppLayout/AppLayout.tsx#L95) — *outside* `{children}`,
|
||||
so the gate cannot reach it:
|
||||
|
||||
```tsx
|
||||
{children} // line 85 ← Gated lives in here
|
||||
...
|
||||
{footer && <AdhesiveFooter />} // line 95 ← not gated
|
||||
```
|
||||
|
||||
Since a prebid auction only runs for a defined slot, removing the adhesive slot removes both
|
||||
the GAM request and all 1145 SSP calls.
|
||||
|
||||
**We do not need to block `loader.js`.** The Snigel loader, `gpt.js`, and the adengine
|
||||
bootstrap are inert on their own — they create no inventory without a slot request. Blocking
|
||||
them would require resolving the gate before `_app` renders (i.e. server-side plumbing
|
||||
through `getServerSideProps` → `pageProps`), which is a much larger change for no additional
|
||||
policy benefit. See "Deferred" below for the one residual it would buy us.
|
||||
|
||||
## The rule: content rating, not gate state
|
||||
|
||||
The decision is about the **content**, not the viewer.
|
||||
[`isAdGatedContent`](../src/shared/utils/ad-gating.ts) is the whole of it:
|
||||
|
||||
```ts
|
||||
return !!nsfw || !hasSafeBrowsingLevel(contentNsfwLevel);
|
||||
```
|
||||
|
||||
| Content | Ads |
|
||||
| --- | --- |
|
||||
| PG | serve |
|
||||
| PG13 | serve |
|
||||
| R / X / XXX | block |
|
||||
| unrated (0) | block — rating unknown |
|
||||
|
||||
**civitai.com serves PG *and* PG13**, so a PG13 page is ad-safe even when an anonymous viewer
|
||||
sees a login gate instead of the content. An earlier cut of this keyed off the `Gated` verdict
|
||||
(`state !== 'page'`), which blocked ads on every PG13 page for logged-out users — most of the
|
||||
site's ad traffic. Don't reintroduce that.
|
||||
|
||||
It's viewer-independent for a second reason: one auction is enough to put a URL in GAM's
|
||||
policy violation center, so an owner, moderator, or crawler must not be able to monetize a URL
|
||||
that's gated for everyone else. No `bypassRating`, no session, no `verifiedBot`. Server and
|
||||
client evaluate the identical expression, so they cannot disagree and there's no hydration
|
||||
mismatch.
|
||||
|
||||
## The wiring
|
||||
|
||||
**SSR** — pages that render `<Gated>` return a rating alongside their props:
|
||||
|
||||
```ts
|
||||
return { props: { id }, gating: { contentNsfwLevel: model.nsfwLevel, nsfw: model.nsfw } };
|
||||
```
|
||||
|
||||
`createServerSideProps` consumes `gating` (it never reaches Next.js), resolves it, and merges
|
||||
`adsGated` into props. `_app` reads that and passes `<AdsProvider gated={adsGated}>`. The type
|
||||
is `AdGatingDeclaration`, wired into the resolver signature so a typo'd key fails typecheck.
|
||||
|
||||
This has to be server-side: [`AdUnitRenderable`](../src/components/Ads/AdUnitRenderable.tsx#L14)
|
||||
renders whenever `adsEnabled` is true, including during SSR, so the reserved-height ad slot
|
||||
ships in the HTML and paints before hydration. An effect is too late — that's the flash.
|
||||
|
||||
**Client navigation** — `Gated` calls `useAdGate(isAdGatedContent({ contentNsfwLevel, nsfw }))`.
|
||||
No SSR HTML to flash there, so a layout effect lands before paint. `createServerSideProps`
|
||||
skips `ssg` on client-nav data fetches, so the server value is SSR-only by design and these
|
||||
two mechanisms are complements, not redundancy.
|
||||
|
||||
**Where it applies** — one term on the context value, not on the local `adsEnabled`:
|
||||
|
||||
```ts
|
||||
adsEnabled: adsEnabled && !((gated || gateBlocked) && !useDirectAds),
|
||||
```
|
||||
|
||||
Ad units all read `adsEnabled` from context, so this reaches every one of them — including the
|
||||
adhesive footer that started this, which renders outside the page tree. Meanwhile the
|
||||
`<Script>` blocks and the adblock probe keep using the ungated local:
|
||||
|
||||
- the Snigel loader still mounts on gated pages. It's inert without a slot, and it drives the
|
||||
CMP handshake that sets `ready` — skipping it when a session starts on a gated page would
|
||||
delay the first ad on every later page by a full round-trip.
|
||||
- the `.red` adblock probe still runs. It hits our own ad server for detection rather than
|
||||
requesting an ad; skipping it would leave `adsBlocked` unresolved for the rest of the
|
||||
session, breaking the closeable-bar logic and the `SupportUs` fallback.
|
||||
|
||||
**Never applies to `.red`.** It serves direct ads through `CivitaiAdUnit`, already
|
||||
`browsingLevel`-aware, with no GAM auction and no policy center. `!useDirectAds` enforces it
|
||||
client-side, `!features.canViewNsfw` server-side.
|
||||
|
||||
### Client-side slot teardown
|
||||
|
||||
Mostly already handled: `AdUnitContent`'s cleanup calls `googletag.destroySlots()` on unmount
|
||||
([lines 62-71](../src/components/Ads/AdUnitFactory.tsx#L62-L71)), so a unit that stops
|
||||
rendering tears down its own slot. Verify that the adhesive unit actually unmounts (rather
|
||||
than just hiding) when `adsEnabled` flips mid-session, and that no stale slot survives a
|
||||
transition into a gated page.
|
||||
|
||||
### Known gap
|
||||
|
||||
Images and posts gate on `forcedBrowsingLevel || nsfwLevel`, where the forced level comes from
|
||||
contest-collection details. That isn't prefetched for logged-out users, so the server and
|
||||
client agree for the traffic that matters — but an authenticated user on a forced-level
|
||||
contest collection can still see the old flash.
|
||||
|
||||
### Verification
|
||||
|
||||
Run `node scripts/ad-request-check.mjs [origin]`.
|
||||
|
||||
**This cannot be verified locally.** `adsEnabled` is hard-`false` when `isDev`
|
||||
([`AdsProvider.tsx:125`](../src/components/Ads/AdsProvider.tsx#L125)), so a dev server never
|
||||
requests ads at all and would pass the gated check for the wrong reason. Point the script at
|
||||
a preview or production deploy.
|
||||
|
||||
Pass criteria, logged out:
|
||||
|
||||
- gated (R+) URL: `gampad/ads` count **0**, prebid/SSP calls **0**, defined GPT slots `[]`
|
||||
- control URL: unchanged — 1 request, `side_1` + `side_2` + `adhesive` still defined
|
||||
- **a PG13 URL, logged out: ads still serve.** This is the case most likely to regress
|
||||
silently, since over-blocking looks like "working" on the gated check alone.
|
||||
|
||||
---
|
||||
|
||||
## Surfaces
|
||||
|
||||
Each declares `gating` in its resolver; `<Gated>` is unchanged apart from the `useAdGate` call.
|
||||
|
||||
| Surface | `<Gated>` call site | `gating` declared in |
|
||||
| --- | --- | --- |
|
||||
| Models | [`models/[id]/[[...slug]].tsx:824`](../src/pages/models/[id]/[[...slug]].tsx#L824) | same file |
|
||||
| Images | [`ImageDetail2.tsx:319`](../src/components/Image/DetailV2/ImageDetail2.tsx#L319) | `images/[imageId].tsx` |
|
||||
| Posts | [`PostDetail.tsx:169`](../src/components/Post/Detail/PostDetail.tsx#L169) | `posts/[postId]/[[...postSlug]].tsx` |
|
||||
| Articles | [`articles/[id]/[[...slug]].tsx:315`](../src/pages/articles/[id]/[[...slug]].tsx#L315) | same file |
|
||||
| Bounties | [`bounties/[id]/[[...slug]].tsx:202`](../src/pages/bounties/[id]/[[...slug]].tsx#L202) | same file |
|
||||
| Challenges | [`challenges/[id]/[[...slug]].tsx:473`](../src/pages/challenges/[id]/[[...slug]].tsx#L473) | same file |
|
||||
| 3D models | [`3d-models/[id]/[[...slug]].tsx:345`](../src/pages/3d-models/[id]/[[...slug]].tsx#L345) | same file |
|
||||
| Collections | [`Collection.tsx:557`](../src/components/Collections/Collection.tsx#L557) | `collections/[collectionId]/index.tsx` |
|
||||
|
||||
The failure mode to watch is a `gating` declaration drifting from its `<Gated>` props. 3D
|
||||
models gained `useSSG: true` plus a `model3d.getById` fetch — it previously SSR'd a loader
|
||||
with no `<Gated>` mounted, shipping an ad slot every time.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
**@ai:\* Should the gate become a real HTTP redirect instead of a 200 card?**
|
||||
Everything above treats the `200 OK` + render-swap as fixed. If gated `.com` URLs returned
|
||||
`301`/`308` to `.red`, the ad problem disappears as a side effect *and* the URLs leave GAM's
|
||||
and Google's inventory entirely — which is the direct answer to the provider's point that
|
||||
these URLs "still live on the .com domain."
|
||||
|
||||
That's the stronger fix, but it costs the interstitial (no explanation of the split, no
|
||||
"same account / Buzz carries over" reassurance). Workstream 1 is now small enough that it's
|
||||
worth shipping regardless — but this decision still stands on its own, since a 301 is the
|
||||
only thing that gets these URLs out of Google's index as well as GAM's inventory.
|
||||
|
||||
---
|
||||
|
||||
## Workstream 2: audit model names and page content for ad-unsafe text
|
||||
|
||||
Workstream 1 only helps pages the gate already catches. Most of the provider's examples are
|
||||
**not** gated — they are rated SFW by our rules and still got flagged:
|
||||
|
||||
| Flagged URL | Why it tripped |
|
||||
| --- | --- |
|
||||
| `/models/1166008/undressing-clothes-over-head` | title only — provider notes the image "might appear normal" |
|
||||
| `/models/452459/krea2-gpt-grand-pussy-truth-or-mist2` | slug, plus description behind "Show more" |
|
||||
| `/models/2107271/frontbend` | image classifier read the subject as a minor |
|
||||
|
||||
The lesson: **our NSFW rating and GAM ad-safety are different thresholds.** A model can be
|
||||
correctly rated SFW for Civitai and still be unmonetizable. We currently have no
|
||||
representation of the second thing.
|
||||
|
||||
### What already exists
|
||||
|
||||
[`entity-moderation.ts:201-204`](../src/server/jobs/entity-moderation.ts#L201-L204) already
|
||||
queues and scans `Model.name` and `Model.description` through
|
||||
[`text-moderation.service.ts`](../src/server/services/text-moderation.service.ts), with
|
||||
policies managed via the XGuard scanner services. This is a tuning and coverage problem, not
|
||||
a greenfield build — resist writing a parallel scanner.
|
||||
|
||||
### Checklist
|
||||
|
||||
#### Define the standard
|
||||
|
||||
- [ ] Get the full ~200 flagged URL list out of GAM from the provider — ground truth to
|
||||
calibrate against, and the regression set for any classifier change
|
||||
- [ ] Write down the ad-safety standard as distinct from our content policy (Google's
|
||||
publisher policies for Adult / CSAE are the source, not our TOS)
|
||||
- [ ] Decide the label vocabulary — at minimum `adUnsafe` as a boolean; better, a reason code
|
||||
so CSAE-adjacent hits can be routed to human review rather than just demonetized
|
||||
|
||||
#### Close coverage gaps
|
||||
|
||||
- [ ] `ModelVersion.name` is unscanned — there's a `// TODO possibly add modelVersion` at
|
||||
[`entity-moderation.ts:180`](../src/server/jobs/entity-moderation.ts#L180). Version
|
||||
names render on the page and reach the `<title>`
|
||||
- [ ] Tags and trigger words — both render on the page, neither is in the queue config
|
||||
- [ ] Description behind "Show more" — confirm we scan the full field, not a truncated
|
||||
preview (the `krea2` example was specifically flagged on hidden text)
|
||||
- [ ] Page-level text GAM sees but we don't own: `<title>` composition, OG description,
|
||||
and on-page comments
|
||||
- [ ] Slug is derived from `name`, so it's covered transitively — but confirm historical
|
||||
slugs don't survive a rename (a clean rename with a dirty legacy slug still serves)
|
||||
|
||||
#### Backfill
|
||||
|
||||
- [ ] One-off sweep over all published models visible on `.com`, scored against the
|
||||
ad-safety standard — this is the bulk of the ~200 and won't be caught by any
|
||||
go-forward queue
|
||||
- [ ] Prioritize by ad impressions, not by model age — a flagged page with no traffic costs
|
||||
nothing; `side_1` inventory since 2026-07-13 is where the damage is
|
||||
- [ ] Reconcile results against the provider's list; anything they flagged that we score
|
||||
clean is a calibration failure worth understanding before shipping
|
||||
|
||||
#### Wire the outcome
|
||||
|
||||
- [ ] `adUnsafe` should suppress ads on that page — reuse the gate store from Workstream 1
|
||||
rather than inventing a second suppression path. This is the main architectural payoff
|
||||
of doing them together
|
||||
- [ ] Decide separately whether `adUnsafe` also implies `deIndex`. It shouldn't by default —
|
||||
demonetizing is cheap and reversible, deindexing costs organic traffic
|
||||
- [ ] Route CSAE-adjacent hits to human moderation queue, never auto-action
|
||||
- [ ] Re-scan on edit — a model renamed after publish currently keeps its original verdict
|
||||
|
||||
#### Open
|
||||
|
||||
- [ ] Do we demonetize the page, or fix/rename the model? Renaming breaks inbound links and
|
||||
is creator-hostile at scale; demonetizing is invisible and reversible. Probably
|
||||
demonetize by default and reserve renames for the worst offenders — needs a call
|
||||
- [ ] Is there an appeal path for creators whose model gets demonetized? Relevant if any
|
||||
creator revenue is tied to page monetization
|
||||
|
||||
## Deferred: blocking the loader entirely
|
||||
|
||||
Suppressing the slot stops the ad request, but Snigel still registers a **pageview** for the
|
||||
gated URL (loader + adengine still boot). That doesn't create GAM policy exposure, but it does
|
||||
dilute our pageview-to-request ratio in Snigel's reporting — plausibly part of why the
|
||||
provider sees "pages delivering no ads."
|
||||
|
||||
The `adsGated` prop already tells `_app` before render, so the loader could be withheld by
|
||||
moving that term onto the local `adsEnabled`. Deliberately not done — it would cost the CMP
|
||||
handshake on any session that starts at a gated URL (see "Where it applies"). Revisit only if
|
||||
the provider says the pageview-to-request ratio matters.
|
||||
|
||||
## Out of scope (tracked separately)
|
||||
|
||||
- `robots.txt` blocks `/search/*` and `/*?query=`
|
||||
([`robots.txt/index.tsx:18,61-62`](../src/pages/robots.txt/index.tsx#L18)) while we serve ads
|
||||
there. Google's ad crawlers ignore the `*` group, so they need explicit `AdsBot-Google` /
|
||||
`Mediapartners-Google` allow groups — otherwise GAM can't classify those pages and they sit
|
||||
at a CPM floor.
|
||||
- The `side_1` sticky skyscraper shipped 2026-07-13 (`41a8a6ace0`), matching the provider's
|
||||
reported July 14 pageview spike. Not a bug, but it widened coverage across all model pages,
|
||||
which is why this became visible now.
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Verifies that gated pages make no ad requests.
|
||||
*
|
||||
* A gated page that runs an auction sends its URL to GAM, which lands NSFW URLs in the
|
||||
* policy violation center even though the gate hides the content. See docs/ads-gating-plan.md.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/ad-request-check.mjs [origin]
|
||||
*
|
||||
* Defaults to https://civitai.com. Ads are disabled when `isDev` is true, so this cannot be
|
||||
* run against a local dev server — point it at a preview or production deploy.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const origin = process.argv[2] ?? 'https://civitai.com';
|
||||
|
||||
const targets = [
|
||||
{ label: 'GATED', path: '/models/1972981/sex-nudes-other-fun-stuff-snofs', expectAds: false },
|
||||
{ label: 'CONTROL', path: '/models/1166008', expectAds: true },
|
||||
];
|
||||
|
||||
const UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
let failed = false;
|
||||
|
||||
for (const { label, path, expectAds } of targets) {
|
||||
const ctx = await browser.newContext({ userAgent: UA, viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
const adRequests = [];
|
||||
const auctionCalls = [];
|
||||
page.on('request', (r) => {
|
||||
const u = r.url();
|
||||
if (/gampad\/ads|\/pagead\/ads/.test(u)) adRequests.push(u);
|
||||
else if (/prebid|adnxs|rubiconproject|pubmatic|casalemedia|openx|criteo/i.test(u))
|
||||
auctionCalls.push(u);
|
||||
});
|
||||
|
||||
const url = `${origin}${path}`;
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'load', timeout: 60000 });
|
||||
} catch (e) {
|
||||
console.log(` goto warning: ${e.message.slice(0, 80)}`);
|
||||
}
|
||||
// Auctions need the CMP round-trip plus the adSizes effect, and side rails need a scroll.
|
||||
await page.waitForTimeout(14000);
|
||||
await page.evaluate(() => window.scrollBy(0, 1200)).catch(() => {});
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
const slots = await page.evaluate(() => {
|
||||
try {
|
||||
return window.googletag?.pubads?.().getSlots?.().map((s) => s.getAdUnitPath()) ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const ok = expectAds
|
||||
? adRequests.length > 0 && slots.length > 0
|
||||
: adRequests.length === 0 && auctionCalls.length === 0 && slots.length === 0;
|
||||
if (!ok) failed = true;
|
||||
|
||||
console.log(`\n${ok ? 'PASS' : 'FAIL'} ${label} ${url}`);
|
||||
console.log(` GAM ad requests: ${adRequests.length} auction calls: ${auctionCalls.length}`);
|
||||
console.log(` defined slots: ${JSON.stringify(slots)}`);
|
||||
console.log(` expected: ${expectAds ? 'ads served' : 'no ad requests at all'}`);
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n${failed ? 'FAILED' : 'All checks passed'}`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -7,6 +7,7 @@ import { useThirdPartyConsent } from '~/components/Consent/consent.context';
|
||||
import { useSignalContext } from '~/components/Signals/SignalsProvider';
|
||||
import { isDev } from '~/env/other';
|
||||
import { useCurrentUser } from '~/hooks/useCurrentUser';
|
||||
import { useIsomorphicLayoutEffect } from '~/hooks/useIsomorphicLayoutEffect';
|
||||
import { useBrowsingSettings } from '~/providers/BrowserSettingsProvider';
|
||||
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
|
||||
|
||||
@@ -39,6 +40,7 @@ const useAdProviderStore = create<{
|
||||
adsBlocked?: boolean;
|
||||
consent: boolean;
|
||||
browserBlocked: boolean;
|
||||
gateBlocked: boolean;
|
||||
}>(() => ({
|
||||
ready: false,
|
||||
// Tri-state: undefined = detection pending, false = confirmed serving (loader onLoad / direct-ad
|
||||
@@ -49,8 +51,18 @@ const useAdProviderStore = create<{
|
||||
adsBlocked: undefined,
|
||||
consent: true,
|
||||
browserBlocked: false,
|
||||
gateBlocked: false,
|
||||
}));
|
||||
|
||||
/** Covers client-side navigation; SSR uses the `gated` prop, which lands before first paint. */
|
||||
export function useAdGate(blocked: boolean) {
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (!blocked) return;
|
||||
useAdProviderStore.setState({ gateBlocked: true });
|
||||
return () => useAdProviderStore.setState({ gateBlocked: false });
|
||||
}, [blocked]);
|
||||
}
|
||||
|
||||
const blockedUrls: string[] = [
|
||||
'/collections/6503138',
|
||||
'/collections/7514194',
|
||||
@@ -58,12 +70,21 @@ const blockedUrls: string[] = [
|
||||
'/moderator',
|
||||
];
|
||||
|
||||
export function AdsProvider({ children }: { children: React.ReactNode }) {
|
||||
export function AdsProvider({
|
||||
children,
|
||||
gated = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** From `pageProps`. `Gated` mounts below this provider, so waiting for its effect would
|
||||
* server-render an ad slot and flash it before hydration. */
|
||||
gated?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const ready = useAdProviderStore((state) => state.ready);
|
||||
const adsBlocked = useAdProviderStore((state) => state.adsBlocked);
|
||||
const consent = useAdProviderStore((state) => state.consent);
|
||||
const browserBlocked = useAdProviderStore((state) => state.browserBlocked);
|
||||
const gateBlocked = useAdProviderStore((state) => state.gateBlocked);
|
||||
const currentUser = useCurrentUser();
|
||||
const features = useFeatureFlags();
|
||||
const { allowed: consentAllowed } = useThirdPartyConsent();
|
||||
@@ -150,7 +171,10 @@ export function AdsProvider({ children }: { children: React.ReactNode }) {
|
||||
ready,
|
||||
adsBlocked,
|
||||
consent,
|
||||
adsEnabled,
|
||||
// Gated here rather than on the local `adsEnabled` so the Snigel loader still mounts
|
||||
// (it's inert without a slot, and it drives the CMP handshake that sets `ready`), and
|
||||
// so the adhesive footer — rendered outside the page tree — is covered too.
|
||||
adsEnabled: adsEnabled && !((gated || gateBlocked) && !useDirectAds),
|
||||
useDirectAds,
|
||||
username: currentUser?.username,
|
||||
isMember,
|
||||
|
||||
@@ -26,7 +26,9 @@ import type { MediaType } from '~/shared/utils/prisma/enums';
|
||||
import { Meta, type MetaProps } from '~/components/Meta/Meta';
|
||||
import { PageLoader } from '~/components/PageLoader/PageLoader';
|
||||
import { requireLogin } from '~/components/Login/requireLogin';
|
||||
import { useAdGate } from '~/components/Ads/AdsProvider';
|
||||
import { useAppContext, useServerDomains } from '~/providers/AppProvider';
|
||||
import { isAdGatedContent } from '~/shared/utils/ad-gating';
|
||||
import { syncAccount } from '~/utils/sync-account';
|
||||
import { outerCardStyle } from '~/components/Buzz/CryptoDeposit/crypto-deposit.constants';
|
||||
|
||||
@@ -162,6 +164,9 @@ export function Gated<TImage extends { nsfwLevel: number; url: string; type?: Me
|
||||
const { state, isPaywalled } = useGated({ contentNsfwLevel, nsfw, bypassRating });
|
||||
const { allowMatureContent } = useAppContext();
|
||||
|
||||
// Rating, not `state` — a PG13 page still serves ads while showing a login gate.
|
||||
useAdGate(isAdGatedContent({ contentNsfwLevel, nsfw }));
|
||||
|
||||
// Whether the content is canonically SFW for the purpose of the deindex
|
||||
// decision. Some entities (e.g. `Model`) carry a coarse `nsfw` boolean
|
||||
// override — when truthy, force-treat the content as NSFW regardless of
|
||||
|
||||
@@ -79,9 +79,7 @@ import { trpc } from '~/utils/trpc';
|
||||
// Dynamic, ssr-disabled import — three.js needs WebGL which only exists in the browser.
|
||||
const Model3DVariantViewer = dynamic(
|
||||
() =>
|
||||
import('~/components/Model3D/Viewer/Model3DVariantViewer').then(
|
||||
(m) => m.Model3DVariantViewer
|
||||
),
|
||||
import('~/components/Model3D/Viewer/Model3DVariantViewer').then((m) => m.Model3DVariantViewer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
@@ -103,14 +101,23 @@ const querySchema = z.object({
|
||||
});
|
||||
|
||||
export const getServerSideProps = createServerSideProps({
|
||||
useSSG: true,
|
||||
useSession: true,
|
||||
resolver: async ({ ctx, features }) => {
|
||||
resolver: async ({ ctx, ssg, features }) => {
|
||||
// Gate at SSR — avoids a flash of <NotFound /> while FeatureFlagsProvider's
|
||||
// user-features tRPC query is still in flight on the client.
|
||||
if (!features?.model3dFeed) return { notFound: true };
|
||||
const result = querySchema.safeParse(ctx.query);
|
||||
if (!result.success) return { notFound: true };
|
||||
return { props: removeEmpty(result.data) };
|
||||
|
||||
// Client-fetched previously; without this the page SSRs a loader with no <Gated> mounted,
|
||||
// shipping an ad slot in the HTML.
|
||||
const model3d = await ssg?.model3d.getById.fetch({ id: result.data.id }).catch(() => null);
|
||||
|
||||
return {
|
||||
props: removeEmpty(result.data),
|
||||
gating: model3d ? { contentNsfwLevel: model3d.nsfwLevel ?? 0 } : undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -186,8 +193,7 @@ function Model3DDetailsPage({ id }: InferGetServerSidePropsType<typeof getServer
|
||||
if (primaryFile && !selectedFileKey) setSelectedFileKey(getFileKey(primaryFile));
|
||||
}, [primaryFile, selectedFileKey]);
|
||||
|
||||
const selectedFile =
|
||||
files.find((f) => getFileKey(f) === selectedFileKey) ?? primaryFile ?? null;
|
||||
const selectedFile = files.find((f) => getFileKey(f) === selectedFileKey) ?? primaryFile ?? null;
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!selectedFile?.downloadUrl) {
|
||||
@@ -314,8 +320,7 @@ function Model3DDetailsPage({ id }: InferGetServerSidePropsType<typeof getServer
|
||||
// on walking/running play automatically via the viewer's AnimationMixer.
|
||||
const viewableVariants: Model3DViewableVariant[] = files
|
||||
.filter(
|
||||
(f) =>
|
||||
f.format.toLowerCase() === 'glb' && !(f.variant ?? 'primary').endsWith('-armature')
|
||||
(f) => f.format.toLowerCase() === 'glb' && !(f.variant ?? 'primary').endsWith('-armature')
|
||||
)
|
||||
.map((f) => ({
|
||||
key: getFileKey(f),
|
||||
@@ -568,167 +573,159 @@ function Model3DDetailsPage({ id }: InferGetServerSidePropsType<typeof getServer
|
||||
</Card>
|
||||
);
|
||||
|
||||
const detailsBlock = (hasGenerationData || reviewSummary) ? (
|
||||
<Accordion
|
||||
variant="separated"
|
||||
multiple
|
||||
defaultValue={['details']}
|
||||
styles={(t) => ({
|
||||
content: { padding: 0 },
|
||||
label: { padding: 0 },
|
||||
item: {
|
||||
overflow: 'hidden',
|
||||
borderColor:
|
||||
colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[3],
|
||||
boxShadow: t.shadows.sm,
|
||||
},
|
||||
control: {
|
||||
padding: t.spacing.sm,
|
||||
gap: t.spacing.md,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Accordion.Item value="details">
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between">
|
||||
Details
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconWand size={12} />}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openReviewModal();
|
||||
}}
|
||||
>
|
||||
Write a review
|
||||
</Button>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel p={0}>
|
||||
<Stack
|
||||
gap={0}
|
||||
style={{
|
||||
backgroundColor:
|
||||
colorScheme === 'dark' ? '#1f2023' : theme.colors.gray[0],
|
||||
const detailsBlock =
|
||||
hasGenerationData || reviewSummary ? (
|
||||
<Accordion
|
||||
variant="separated"
|
||||
multiple
|
||||
defaultValue={['details']}
|
||||
styles={(t) => ({
|
||||
content: { padding: 0 },
|
||||
label: { padding: 0 },
|
||||
item: {
|
||||
overflow: 'hidden',
|
||||
borderColor: colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[3],
|
||||
boxShadow: t.shadows.sm,
|
||||
},
|
||||
control: {
|
||||
padding: t.spacing.sm,
|
||||
gap: t.spacing.md,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Accordion.Item value="details">
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between">
|
||||
Details
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconWand size={12} />}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openReviewModal();
|
||||
}}
|
||||
>
|
||||
{/* Reviews row */}
|
||||
Write a review
|
||||
</Button>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel p={0}>
|
||||
<Stack
|
||||
gap={0}
|
||||
style={{
|
||||
backgroundColor:
|
||||
colorScheme === 'dark' ? '#1f2023' : theme.colors.gray[0],
|
||||
}}
|
||||
>
|
||||
{/* Reviews row */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
px="md"
|
||||
py={10}
|
||||
style={{
|
||||
borderBottom: `1px solid ${
|
||||
colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
Reviews
|
||||
</Text>
|
||||
{recommendPct !== null ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
href={`/3d-models/${id}/reviews`}
|
||||
underline="hover"
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
{recommendPct >= 50 ? (
|
||||
<IconThumbUp size={14} />
|
||||
) : (
|
||||
<IconThumbDown size={14} />
|
||||
)}
|
||||
<Text size="sm" fw={500}>
|
||||
{sentimentLabel(recommendPct, ratingCount)}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{recommendPct}% · {abbreviateNumber(ratingCount)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Anchor>
|
||||
) : (
|
||||
<Anchor component={Link} href={`/3d-models/${id}/reviews`} size="sm">
|
||||
No reviews yet
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{model3d.sourceImage && (
|
||||
<Group
|
||||
align="flex-start"
|
||||
justify="space-between"
|
||||
px="md"
|
||||
py={10}
|
||||
style={{
|
||||
borderBottom: `1px solid ${
|
||||
colorScheme === 'dark'
|
||||
? theme.colors.dark[4]
|
||||
: theme.colors.gray[3]
|
||||
colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
Reviews
|
||||
Source image
|
||||
</Text>
|
||||
{recommendPct !== null ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
href={`/3d-models/${id}/reviews`}
|
||||
underline="hover"
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
{recommendPct >= 50 ? (
|
||||
<IconThumbUp size={14} />
|
||||
) : (
|
||||
<IconThumbDown size={14} />
|
||||
)}
|
||||
<Text size="sm" fw={500}>
|
||||
{sentimentLabel(recommendPct, ratingCount)}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{recommendPct}% · {abbreviateNumber(ratingCount)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Anchor>
|
||||
) : (
|
||||
<Anchor
|
||||
component={Link}
|
||||
href={`/3d-models/${id}/reviews`}
|
||||
size="sm"
|
||||
>
|
||||
No reviews yet
|
||||
</Anchor>
|
||||
)}
|
||||
<Link
|
||||
href={`/images/${model3d.sourceImage.id}`}
|
||||
className="block w-[120px] overflow-hidden rounded-md border border-solid border-dark-4"
|
||||
>
|
||||
<EdgeMedia
|
||||
src={model3d.sourceImage.url}
|
||||
name={model3d.sourceImage.name ?? undefined}
|
||||
type={
|
||||
(model3d.sourceImage.type as
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| undefined) ?? undefined
|
||||
}
|
||||
width={240}
|
||||
anim={false}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</Link>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{model3d.sourceImage && (
|
||||
<Group
|
||||
align="flex-start"
|
||||
justify="space-between"
|
||||
px="md"
|
||||
py={10}
|
||||
style={{
|
||||
borderBottom: `1px solid ${
|
||||
colorScheme === 'dark'
|
||||
? theme.colors.dark[4]
|
||||
: theme.colors.gray[3]
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
Source image
|
||||
</Text>
|
||||
<Link
|
||||
href={`/images/${model3d.sourceImage.id}`}
|
||||
className="block w-[120px] overflow-hidden rounded-md border border-solid border-dark-4"
|
||||
>
|
||||
<EdgeMedia
|
||||
src={model3d.sourceImage.url}
|
||||
name={model3d.sourceImage.name ?? undefined}
|
||||
type={
|
||||
(model3d.sourceImage.type as
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| undefined) ?? undefined
|
||||
}
|
||||
width={240}
|
||||
anim={false}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</Link>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{generationDetailItems.map(([label, value], i) => (
|
||||
<Group
|
||||
key={label}
|
||||
justify="space-between"
|
||||
px="md"
|
||||
py={10}
|
||||
style={{
|
||||
borderBottom:
|
||||
i === generationDetailItems.length - 1
|
||||
? 'none'
|
||||
: `1px solid ${
|
||||
colorScheme === 'dark'
|
||||
? theme.colors.dark[4]
|
||||
: theme.colors.gray[3]
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" ta="right" style={{ wordBreak: 'break-word' }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null;
|
||||
{generationDetailItems.map(([label, value], i) => (
|
||||
<Group
|
||||
key={label}
|
||||
justify="space-between"
|
||||
px="md"
|
||||
py={10}
|
||||
style={{
|
||||
borderBottom:
|
||||
i === generationDetailItems.length - 1
|
||||
? 'none'
|
||||
: `1px solid ${
|
||||
colorScheme === 'dark'
|
||||
? theme.colors.dark[4]
|
||||
: theme.colors.gray[3]
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" ta="right" style={{ wordBreak: 'break-word' }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null;
|
||||
|
||||
const descriptionBlock = model3d.description ? (
|
||||
<ContentClamp maxHeight={460}>
|
||||
@@ -815,16 +812,13 @@ function Model3DDetailsPage({ id }: InferGetServerSidePropsType<typeof getServer
|
||||
</ContainerGrid2>
|
||||
);
|
||||
})()}
|
||||
|
||||
</Stack>
|
||||
</Container>
|
||||
{/* Community gallery — rendered OUTSIDE the size="xl" Container so the
|
||||
masonry can claim the full page width and pack 6–7 cards across on
|
||||
wide screens (matching the model-detail page bottom gallery). */}
|
||||
<Box id="gallery" mt="md">
|
||||
<Model3DGallery
|
||||
model3d={{ id, userId: model3d.userId, minor: model3d.minor }}
|
||||
/>
|
||||
<Model3DGallery model3d={{ id, userId: model3d.userId, minor: model3d.minor }} />
|
||||
</Box>
|
||||
</Gated>
|
||||
);
|
||||
|
||||
+3
-1
@@ -132,6 +132,7 @@ type CustomAppProps = {
|
||||
serverDomains: ServerDomains;
|
||||
availableOAuthProviders: string[];
|
||||
verifiedBot: VerifiedBot | null;
|
||||
adsGated?: boolean;
|
||||
}>;
|
||||
|
||||
function MyApp(props: CustomAppProps) {
|
||||
@@ -160,6 +161,7 @@ function MyApp(props: CustomAppProps) {
|
||||
serverDomains,
|
||||
availableOAuthProviders,
|
||||
verifiedBot = null,
|
||||
adsGated = false,
|
||||
...pageProps
|
||||
},
|
||||
} = props;
|
||||
@@ -252,7 +254,7 @@ function MyApp(props: CustomAppProps) {
|
||||
<ActivityReportingProvider>
|
||||
<ReferralsProvider {...cookies.referrals}>
|
||||
<FiltersProvider>
|
||||
<AdsProvider>
|
||||
<AdsProvider gated={adsGated}>
|
||||
<HiddenPreferencesProvider>
|
||||
<CivitaiLinkProvider>
|
||||
<BrowserRouterProvider>
|
||||
|
||||
@@ -94,6 +94,8 @@ export const getServerSideProps = createServerSideProps({
|
||||
const result = querySchema.safeParse(ctx.query);
|
||||
if (!result.success) return { notFound: true };
|
||||
|
||||
let gating: { contentNsfwLevel: number; nsfw?: boolean } | undefined;
|
||||
|
||||
// Redirect old ?imageId= URLs to the clean article URL
|
||||
if (ctx.query.imageId) {
|
||||
const slug = result.data.slug?.join('/');
|
||||
@@ -107,6 +109,11 @@ export const getServerSideProps = createServerSideProps({
|
||||
// Fetch article to check slug and prefetch for client hydration
|
||||
const article = await ssg.article.getById.fetch({ id: result.data.id }).catch(() => null);
|
||||
|
||||
if (article)
|
||||
gating = {
|
||||
contentNsfwLevel: article.nsfwLevel,
|
||||
};
|
||||
|
||||
// Redirect to canonical slug URL if slug is missing or incorrect
|
||||
if (article) {
|
||||
const correctSlug = slugit(article.title);
|
||||
@@ -138,7 +145,7 @@ export const getServerSideProps = createServerSideProps({
|
||||
await ssg.hiddenPreferences.getHidden.prefetch();
|
||||
}
|
||||
|
||||
return { props: removeEmpty(result.data) };
|
||||
return { props: removeEmpty(result.data), gating };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -112,10 +112,18 @@ export const getServerSideProps = createServerSideProps({
|
||||
const result = querySchema.safeParse(ctx.query);
|
||||
if (!result.success) return { notFound: true };
|
||||
|
||||
let gating: { contentNsfwLevel: number; nsfw?: boolean } | undefined;
|
||||
|
||||
if (ssg) {
|
||||
// Fetch bounty to check slug and prefetch for client hydration
|
||||
const bounty = await ssg.bounty.getById.fetch({ id: result.data.id }).catch(() => null);
|
||||
|
||||
if (bounty)
|
||||
gating = {
|
||||
contentNsfwLevel: bounty.nsfwLevel,
|
||||
nsfw: bounty.nsfw,
|
||||
};
|
||||
|
||||
// Redirect to canonical slug URL if slug is missing or incorrect
|
||||
if (bounty) {
|
||||
const correctSlug = slugit(bounty.name);
|
||||
@@ -134,7 +142,7 @@ export const getServerSideProps = createServerSideProps({
|
||||
await ssg.hiddenPreferences.getHidden.prefetch();
|
||||
}
|
||||
|
||||
return { props: removeEmpty(result.data) };
|
||||
return { props: removeEmpty(result.data), gating };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -192,10 +192,18 @@ export const getServerSideProps = createServerSideProps({
|
||||
const result = querySchema.safeParse(ctx.query);
|
||||
if (!result.success) return { notFound: true };
|
||||
|
||||
let gating: { contentNsfwLevel: number; nsfw?: boolean } | undefined;
|
||||
|
||||
if (ssg) {
|
||||
// Fetch challenge to check slug and prefetch for client hydration
|
||||
const challenge = await ssg.challenge.getById.fetch({ id: result.data.id }).catch(() => null);
|
||||
|
||||
if (challenge)
|
||||
gating = {
|
||||
contentNsfwLevel: challenge.allowedNsfwLevel | (challenge.coverImage?.nsfwLevel ?? 0),
|
||||
nsfw: challenge.source === ChallengeSource.User && challenge.buzzType === 'yellow',
|
||||
};
|
||||
|
||||
if (challenge) {
|
||||
const destination = getCanonicalSlugDestination({
|
||||
basePath: '/challenges',
|
||||
@@ -208,7 +216,7 @@ export const getServerSideProps = createServerSideProps({
|
||||
}
|
||||
}
|
||||
|
||||
return { props: removeEmpty(result.data) };
|
||||
return { props: removeEmpty(result.data), gating };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -438,10 +446,7 @@ function ChallengeDetailsPage({ id }: InferGetServerSidePropsType<typeof getServ
|
||||
// Delete stays available after a moderator voids the challenge (Cancelled) so the owner can clear
|
||||
// a dead challenge off their list; edit remains Scheduled-only (canManageOwn).
|
||||
const canDeleteOwn =
|
||||
features.userChallenges &&
|
||||
isOwner &&
|
||||
!currentUser?.isModerator &&
|
||||
(isScheduled || isCancelled);
|
||||
features.userChallenges && isOwner && !currentUser?.isModerator && (isScheduled || isCancelled);
|
||||
|
||||
const handleOwnerDelete = () => {
|
||||
openConfirmModal({
|
||||
@@ -928,15 +933,11 @@ function ChallengeSidebar({ challenge }: { challenge: ChallengeDetail }) {
|
||||
const challengeDetails: DescriptionTableProps['items'] = [
|
||||
{
|
||||
label: 'Starts',
|
||||
value: (
|
||||
<Text size="sm">{formatDate(challenge.startsAt, 'MMM DD, YYYY hh:mm A', false)}</Text>
|
||||
),
|
||||
value: <Text size="sm">{formatDate(challenge.startsAt, 'MMM DD, YYYY hh:mm A', false)}</Text>,
|
||||
},
|
||||
{
|
||||
label: 'Ends',
|
||||
value: (
|
||||
<Text size="sm">{formatDate(challenge.endsAt, 'MMM DD, YYYY hh:mm A', false)}</Text>
|
||||
),
|
||||
value: <Text size="sm">{formatDate(challenge.endsAt, 'MMM DD, YYYY hh:mm A', false)}</Text>,
|
||||
},
|
||||
{
|
||||
label: 'Max Entries',
|
||||
@@ -1328,7 +1329,12 @@ function ChallengeSidebar({ challenge }: { challenge: ChallengeDetail }) {
|
||||
Generate
|
||||
</Button>
|
||||
{challenge.collectionId && (
|
||||
<SubmitEntryButton isOwner={isOwner} onClick={handleOpenSubmitModal} label="Submit" fullWidth />
|
||||
<SubmitEntryButton
|
||||
isOwner={isOwner}
|
||||
onClick={handleOpenSubmitModal}
|
||||
label="Submit"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
@@ -1351,7 +1357,12 @@ function ChallengeSidebar({ challenge }: { challenge: ChallengeDetail }) {
|
||||
Generate
|
||||
</Button>
|
||||
{challenge.collectionId && (
|
||||
<SubmitEntryButton isOwner={isOwner} onClick={handleOpenSubmitModal} label="Submit" fullWidth />
|
||||
<SubmitEntryButton
|
||||
isOwner={isOwner}
|
||||
onClick={handleOpenSubmitModal}
|
||||
label="Submit"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : challenge.status === ChallengeStatus.Completed ? (
|
||||
|
||||
@@ -23,9 +23,11 @@ export const getServerSideProps = createServerSideProps({
|
||||
|
||||
if (!features?.collections) return { notFound: true };
|
||||
|
||||
let gating: { contentNsfwLevel: number; nsfw?: boolean } | undefined;
|
||||
|
||||
if (ssg) {
|
||||
await Promise.all([
|
||||
ssg.collection.getById.prefetch({ id: collectionId }),
|
||||
const [data] = await Promise.all([
|
||||
ssg.collection.getById.fetch({ id: collectionId }).catch(() => null),
|
||||
...(session
|
||||
? [
|
||||
ssg.collection.getAllUser.prefetch({
|
||||
@@ -35,12 +37,20 @@ export const getServerSideProps = createServerSideProps({
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const collection = data?.collection;
|
||||
if (collection)
|
||||
gating = {
|
||||
contentNsfwLevel: collection.metadata?.forcedBrowsingLevel || collection.nsfwLevel,
|
||||
nsfw: collection.nsfw ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
collectionId: Number(ctx.query.collectionId),
|
||||
},
|
||||
gating,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,14 +15,24 @@ export const getServerSideProps = createServerSideProps({
|
||||
const id = Number(params.imageId);
|
||||
if (!isNumber(id)) return { notFound: true };
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
ssg?.image.get.prefetch({ id }),
|
||||
ssg?.image.getGenerationData.prefetch({ id }),
|
||||
session ? ssg?.image.getContestCollectionDetails.prefetch({ id }) : null,
|
||||
ssg?.hiddenPreferences.getHidden.prefetch(),
|
||||
].filter(Boolean)
|
||||
);
|
||||
const [image] = await Promise.all([
|
||||
ssg?.image.get.fetch({ id }).catch(() => null),
|
||||
ssg?.image.getGenerationData.prefetch({ id }),
|
||||
session ? ssg?.image.getContestCollectionDetails.prefetch({ id }) : null,
|
||||
ssg?.hiddenPreferences.getHidden.prefetch(),
|
||||
]);
|
||||
|
||||
// ImageDetail2 gates on `forcedBrowsingLevel || nsfwLevel`; the forced level comes from
|
||||
// contest-collection details, which aren't prefetched for logged-out users, so for the
|
||||
// traffic that matters the client lands on the same level we do.
|
||||
return {
|
||||
props: {},
|
||||
gating: image
|
||||
? {
|
||||
contentNsfwLevel: image.nsfwLevel,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -93,10 +93,7 @@ import { ReorderVersionsModal } from '~/components/Modals/ReorderVersionsModal';
|
||||
import { ToggleLockModel } from '~/components/Model/Actions/ToggleLockModel';
|
||||
import { ToggleLockModelComments } from '~/components/Model/Actions/ToggleLockModelComments';
|
||||
import { HowToButton } from '~/components/Model/HowToUseModel/HowToUseModel';
|
||||
import {
|
||||
HIDDEN_METRIC_MESSAGE,
|
||||
HiddenMetricNotice,
|
||||
} from '~/components/Model/HiddenMetricNotice';
|
||||
import { HIDDEN_METRIC_MESSAGE, HiddenMetricNotice } from '~/components/Model/HiddenMetricNotice';
|
||||
import { ModelVersionList } from '~/components/Model/ModelVersionList/ModelVersionList';
|
||||
import { useModelVersionPermission } from '~/components/Model/ModelVersions/model-version.utils';
|
||||
import { ModelVersionDetails } from '~/components/Model/ModelVersions/ModelVersionDetails';
|
||||
@@ -249,6 +246,8 @@ export const getServerSideProps = createServerSideProps({
|
||||
// }
|
||||
// }
|
||||
|
||||
let gating: { contentNsfwLevel: number; nsfw?: boolean } | undefined;
|
||||
|
||||
if (ssg) {
|
||||
// Fetch the model first so we can short-circuit on slug mismatch before
|
||||
// doing any other prefetch work. Stale links from search results /
|
||||
@@ -257,6 +256,12 @@ export const getServerSideProps = createServerSideProps({
|
||||
.fetch({ id, excludeTrainingData: true })
|
||||
.catch(() => null);
|
||||
|
||||
if (model)
|
||||
gating = {
|
||||
contentNsfwLevel: model.nsfwLevel,
|
||||
nsfw: model.nsfw,
|
||||
};
|
||||
|
||||
// Redirect to canonical slug URL if slug is missing or incorrect
|
||||
if (model) {
|
||||
const correctSlug = slugit(model.name);
|
||||
@@ -374,6 +379,7 @@ export const getServerSideProps = createServerSideProps({
|
||||
|
||||
return {
|
||||
props: { id },
|
||||
gating,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -54,7 +54,15 @@ export const getServerSideProps = createServerSideProps({
|
||||
await ssg?.post.getContestCollectionDetails.prefetch({ id: postId });
|
||||
await ssg?.hiddenPreferences.getHidden.prefetch();
|
||||
|
||||
return { props: { postId } };
|
||||
// PostDetail gates on `forcedBrowsingLevel || nsfwLevel`; the forced level comes from
|
||||
// contest-collection details the client only resolves after hydration.
|
||||
return {
|
||||
props: { postId },
|
||||
gating: {
|
||||
contentNsfwLevel: post.nsfwLevel,
|
||||
nsfw: post.nsfw,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching post detail:', error);
|
||||
return { notFound: true };
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '~/server/logging/trpc-serialize-log';
|
||||
|
||||
import { appRouter } from '~/server/routers';
|
||||
import { isAdGatedContent } from '~/shared/utils/ad-gating';
|
||||
import type { FeatureAccess } from '~/server/services/feature-flags.service';
|
||||
import { getFeatureFlagsAsync } from '~/server/services/feature-flags.service';
|
||||
import { getServerAuthSession } from '~/server/auth/get-server-auth-session';
|
||||
@@ -104,9 +105,13 @@ export function createServerSideProps<P>({
|
||||
? await result.props
|
||||
: result.props;
|
||||
|
||||
// `.red` is exempt — it serves direct ads, no GAM auction, no policy center.
|
||||
const adsGated = !!result.gating && !features.canViewNsfw && isAdGatedContent(result.gating);
|
||||
|
||||
return {
|
||||
props: {
|
||||
...(props ?? {}),
|
||||
adsGated,
|
||||
// Success-only: an errored prefetch would put a TRPCError instance in the
|
||||
// dehydrated state, and the devalue write (TRPC_WRITE_DEVALUE) throws on
|
||||
// non-POJOs — turning one failed prefetch into a page-wide SSR 500. Dropping
|
||||
@@ -133,10 +138,20 @@ export function createServerSideProps<P>({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Content rating declared by pages that render `<Gated>`. Consumed here — it never reaches
|
||||
* Next.js — and becomes the `adsGated` prop `_app` hands to `AdsProvider`.
|
||||
*
|
||||
* Rating only, no owner/mod/crawler exemptions: the verdict must be identical for every
|
||||
* request to a URL, since one auction is enough to put it in GAM's policy violation center.
|
||||
*/
|
||||
export type AdGatingDeclaration = { contentNsfwLevel: number; nsfw?: boolean };
|
||||
|
||||
type GetPropsFnResult<P> = {
|
||||
props: P | Promise<P>;
|
||||
redirect: Redirect;
|
||||
notFound: true;
|
||||
gating: AdGatingDeclaration;
|
||||
};
|
||||
|
||||
type CreateServerSidePropsProps<P> = {
|
||||
@@ -148,7 +163,7 @@ type CreateServerSidePropsProps<P> = {
|
||||
requireModerator?: boolean;
|
||||
resolver?: (
|
||||
context: CustomGetServerSidePropsContext
|
||||
) => Promise<GetServerSidePropsResult<P> | void>;
|
||||
) => Promise<(GetServerSidePropsResult<P> & { gating?: AdGatingDeclaration }) | void>;
|
||||
};
|
||||
|
||||
type CustomGetServerSidePropsContext = {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { hasSafeBrowsingLevel } from '~/shared/constants/browsingLevel.constants';
|
||||
|
||||
/**
|
||||
* Whether ads must be suppressed for this content on civitai.com.
|
||||
*
|
||||
* Deliberately not the `Gated` verdict: .com serves PG and PG13, so a PG13 page is ad-safe
|
||||
* even when the viewer sees a login gate instead of the content. Keying off gate state would
|
||||
* kill ads on every PG13 page for logged-out users — most of the site's ad traffic.
|
||||
*
|
||||
* Viewer-independent so the answer is identical for every request to a URL: one auction from
|
||||
* an owner, mod, or crawler is enough to put the URL in GAM's policy violation center.
|
||||
*/
|
||||
export function isAdGatedContent({
|
||||
contentNsfwLevel,
|
||||
nsfw,
|
||||
}: {
|
||||
contentNsfwLevel: number;
|
||||
nsfw?: boolean;
|
||||
}) {
|
||||
return !!nsfw || !hasSafeBrowsingLevel(contentNsfwLevel);
|
||||
}
|
||||
Reference in New Issue
Block a user