feat(shared): add @civitai/shared package (Flags + browsing-levels)

New client-safe, dependency-free package for cross-app pure constants + utilities,
wired into the main app (transpilePackages + workspace dep). First shared-package home
for these primitives; app-level copies can adopt it to dedup later.

- flags.ts: the Flags bitwise utility (port of the main app's ~/shared/utils/flags).
- browsing-levels.ts: NsfwLevel bitwise levels + the generic browsing-level toolkit
  ported from ~/shared/constants/browsingLevel.constants — level groupings + flags,
  parseBitwiseBrowsingLevel/flagifyBrowsingLevel, labels + descriptions, severity/label
  helpers, and the browsing-level predicates. The App-Blocks/orchestrator/deprecated/UI/
  reasons pieces stay app-specific.
- allBrowsingLevelsFlag matches the main app (EXCLUDES Blocked); a distinct
  allBrowsingLevelsWithBlockedFlag covers moderator "show Blocked" contexts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
briant
2026-07-20 12:02:48 -06:00
parent 7cea0c49d2
commit 43b75f54d9
7 changed files with 254 additions and 0 deletions
+1
View File
@@ -141,6 +141,7 @@ export default defineNextConfig(
'superjson',
'@civitai/db-schema',
'@civitai/db',
'@civitai/shared',
'@civitai/buzz',
'@civitai/redis',
'@civitai/clickhouse',
+1
View File
@@ -111,6 +111,7 @@
"@civitai/client": "0.2.0-beta.81",
"@civitai/cybertipline-tools": "^0.1.0",
"@civitai/next-axiom": "^0.17.0",
"@civitai/shared": "workspace:*",
"@clavata/sdk": "^0.2.3",
"@clickhouse/client": "^0.2.2",
"@coinbase/cdp-sdk": "^1.13.0",
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@civitai/shared",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
@@ -0,0 +1,159 @@
import { Flags } from './flags';
// Numeric (bitwise) NSFW levels + the generic browsing-level toolkit — a faithful port of the reusable
// (non-product-specific) parts of the main app's `~/shared/constants/browsingLevel.constants` and its
// `~/server/common/enums` NsfwLevel. These are stable app constants, not Prisma enums, so @civitai/db-schema
// doesn't carry them. Client-safe (no server/env/framework deps) so any app can import them — server code,
// SvelteKit components, and the main Next app alike.
//
// DELIBERATELY EXCLUDED here (main-app / product / UI specific — keep those in the main app): the App-Blocks
// off-site content-rating ladder + domain ceilings, the orchestrator level map, the deprecated NsfwLevel
// enum + maps, Mantine color maps (nsfwLevelColors / votableTagColors), toggleable browsing categories,
// browsingModeDefaults, and the per-level moderation "reasons" copy.
export const NsfwLevel = {
PG: 1,
PG13: 2,
R: 4,
X: 8,
XXX: 16,
Blocked: 32,
} as const;
export function parseBitwiseBrowsingLevel(level: number): number[] {
return Flags.instanceToArray(level);
}
export function flagifyBrowsingLevel(levels: number[]) {
return Flags.arrayToInstance(levels);
}
export type BrowsingLevels = typeof browsingLevels;
export type BrowsingLevel = BrowsingLevels[number];
export const browsingLevels = [
NsfwLevel.PG,
NsfwLevel.PG13,
NsfwLevel.R,
NsfwLevel.X,
NsfwLevel.XXX,
] as const;
export const browsingLevelLabels = {
0: '?',
[NsfwLevel.PG]: 'PG',
[NsfwLevel.PG13]: 'PG-13',
[NsfwLevel.R]: 'R',
[NsfwLevel.X]: 'X',
[NsfwLevel.XXX]: 'XXX',
[NsfwLevel.Blocked]: 'Blocked',
} as const;
export const browsingLevelDescriptions = {
[NsfwLevel.PG]: 'Safe for work. No naughty stuff',
[NsfwLevel.PG13]:
'Revealing clothing, small bulges, subtle nipple outline, posing/sexualized bare chested men, light gore, violence',
[NsfwLevel.R]:
'Adult themes and situations, partial nudity, bikinis, big bulges, sexual situations, graphic violence',
[NsfwLevel.X]: 'Graphic nudity, genitalia, adult objects, or settings',
[NsfwLevel.XXX]:
'Sexual Acts, masturbation, ejaculation, cum, vore, anal gape, extremely disturbing content',
[NsfwLevel.Blocked]: 'Violates our terms of service',
} as const;
// Level groupings + their OR'd flags.
export const publicBrowsingLevelsArray: BrowsingLevel[] = [NsfwLevel.PG];
export const publicBrowsingLevelsFlag = flagifyBrowsingLevel(publicBrowsingLevelsArray);
export const sfwBrowsingLevelsArray: BrowsingLevel[] = [NsfwLevel.PG, NsfwLevel.PG13];
export const sfwBrowsingLevelsFlag = flagifyBrowsingLevel(sfwBrowsingLevelsArray);
export const nsfwBrowsingLevelsArray: number[] = [
NsfwLevel.R,
NsfwLevel.X,
NsfwLevel.XXX,
NsfwLevel.Blocked,
];
export const nsfwBrowsingLevelsFlag = flagifyBrowsingLevel(nsfwBrowsingLevelsArray);
// All rateable levels OR'd together — matches the main app's `allBrowsingLevelsFlag` (EXCLUDES Blocked;
// Blocked is a TOS action, not a rating).
export const allBrowsingLevelsFlag = flagifyBrowsingLevel([...browsingLevels]);
// All levels INCLUDING Blocked — for moderator contexts (e.g. a review-queue browsing filter that shows
// Blocked too). Distinct from `allBrowsingLevelsFlag` on purpose.
export const allBrowsingLevelsWithBlockedFlag = allBrowsingLevelsFlag | NsfwLevel.Blocked;
// Highest-severity bit first — `getHighestBrowsingLevelBit` returns the most severe single-bit value in a
// composite, which is what `getBrowsingLevelLabel` uses to label aggregate levels (e.g. a composite of
// PG | R = 5, which has no direct entry in `browsingLevelLabels`).
const browsingLevelBitsBySeverity: number[] = [
NsfwLevel.Blocked,
NsfwLevel.XXX,
NsfwLevel.X,
NsfwLevel.R,
NsfwLevel.PG13,
NsfwLevel.PG,
];
export function getHighestBrowsingLevelBit(value: number): number {
for (const bit of browsingLevelBitsBySeverity) {
if ((value & bit) !== 0) return bit;
}
return 0;
}
export function getBrowsingLevelLabel(value: number | null | undefined): string {
if (!value) return '?';
const direct = browsingLevelLabels[value as keyof typeof browsingLevelLabels];
if (direct) return direct;
const highest = getHighestBrowsingLevelBit(value);
return highest ? browsingLevelLabels[highest as keyof typeof browsingLevelLabels] : '?';
}
// --- predicates / helpers ---
// Strip the Blocked bit (a TOS action, not a selectable rating); removeFlag is a no-op when it isn't set.
export function onlySelectableLevels(level: number) {
return Flags.removeFlag(level, NsfwLevel.Blocked);
}
// True when `level` contains only public bits (its bits are a subset of publicBrowsingLevelsFlag).
export function getIsPublicBrowsingLevel(level: number) {
return Flags.diff(level, publicBrowsingLevelsFlag) === 0;
}
/** does not include any nsfw level flags */
export function getIsSafeBrowsingLevel(level: number) {
return level !== 0 && !Flags.intersects(level, nsfwBrowsingLevelsFlag);
}
/** includes a level suitable for public browsing */
export function hasPublicBrowsingLevel(level: number) {
return Flags.hasFlag(level, publicBrowsingLevelsFlag);
}
export function hasSafeBrowsingLevel(level: number) {
return Flags.intersects(level, sfwBrowsingLevelsFlag);
}
const explicitBrowsingLevelFlags = flagifyBrowsingLevel([
NsfwLevel.X,
NsfwLevel.XXX,
NsfwLevel.Blocked,
]);
export function getHasExplicitBrowsingLevel(level: number) {
return Flags.intersects(level, explicitBrowsingLevelFlags);
}
export const browsingLevelOr = (array: (number | undefined)[]) =>
array.find((x) => !!x) ?? publicBrowsingLevelsFlag;
// --- moderator-facing level sets (used by the moderator app; not in the main app's constants) ---
// Single-bit levels a moderator can pin content to (excludes Blocked; that's a TOS action, not a rating).
// Callers should re-validate server-side.
export const validNsfwLevels = new Set<number>(browsingLevels);
// Ingestion-error review lets a moderator set any browsing level OR Blocked (a mis-ingested image may be
// TOS-violating), so this set is broader than validNsfwLevels.
export const ingestionErrorLevels = [...browsingLevels, NsfwLevel.Blocked] as const;
export const ingestionErrorLevelSet = new Set<number>(ingestionErrorLevels);
+73
View File
@@ -0,0 +1,73 @@
// Pure bitwise-flag helpers, no deps — client-safe. Mirror of the main app's `~/shared/utils/flags`, moved
// here so cross-app modules (e.g. browsing-levels) can share one implementation.
export abstract class Flags {
private static possibleValues: number[] = (() =>
[...new Array(32)].map((_, i) => Math.pow(2, i)))();
/** true if every bit set in `flag` is also set in `instance`. */
static hasFlag(instance: number, flag: number) {
return (instance | flag) === instance;
}
/** the bits shared between two instances. */
static intersection(instance1: number, instance2: number) {
return instance1 & instance2;
}
static intersects(instance1: number, instance2: number) {
return (instance1 & instance2) !== 0;
}
static addFlag(instance: number, flag: number) {
return instance | flag;
}
static removeFlag(instance: number, flag: number) {
return instance & ~flag;
}
static maxValue(flag: number) {
return Math.max(...this.instanceToArray(flag));
}
static toggleFlag(instance: number, flag: number) {
return this.hasFlag(instance, flag)
? this.removeFlag(instance, flag)
: this.addFlag(instance, flag);
}
/** enum object → array of its numeric values, e.g. `{ user: 1, admin: 4 }` → `[1, 4]`. */
static enumToBitArray(enumValue: object) {
return Object.keys(enumValue).map(Number).filter(Boolean);
}
/** instance → array of the set bits, e.g. `11` → `[1, 2, 8]`. */
static instanceToArray(instance: number) {
return this.possibleValues.filter((x) => this.hasFlag(instance, x));
}
/** array of bit values → instance, e.g. `[1, 2, 4]` → `7`. */
static arrayToInstance(flagsArray: number[]) {
return flagsArray.reduce((agg, cur) => {
const toAdd = this.possibleValues.includes(cur) ? cur : 0;
return agg + toAdd;
}, 0);
}
/** the bitwise difference between two values. */
static diff(a: number, b: number) {
return a & ~b;
}
/** the number of bit positions between two single-bit flag values. */
static distance(a: number, b: number): number {
const pos1 = Math.log2(a);
const pos2 = Math.log2(b);
return Math.abs(pos1 - pos2);
}
static increaseByBits(instance: number, bits = 1) {
return instance << bits;
}
}
+5
View File
@@ -0,0 +1,5 @@
// @civitai/shared — cross-app pure constants + utilities (no DB / env / framework deps). Client-safe, so
// server code, SvelteKit components, and the main Next app can all import from here. Add new broadly
// shared primitives as their own modules and re-export them below.
export * from './flags';
export * from './browsing-levels';
+5
View File
@@ -46,6 +46,9 @@ importers:
'@civitai/next-axiom':
specifier: ^0.17.0
version: 0.17.0(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))
'@civitai/shared':
specifier: workspace:*
version: link:packages/civitai-shared
'@clavata/sdk':
specifier: ^0.2.3
version: 0.2.3
@@ -1263,6 +1266,8 @@ importers:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.9.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(jiti@2.7.0)(jsdom@27.4.0(@noble/hashes@1.8.0)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.12.10(@types/node@20.19.9)(typescript@5.9.3))(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1)
packages/civitai-shared: {}
packages/civitai-storage:
dependencies:
zod: