mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
169e918a8c
## What Completes the `/user/account` redesign behind the `accountSettingsV2` flag. The earlier commits on this branch built the two-pane shell and converted three panes; this finishes the other five, then polishes the result against the Pencil canvas (`designs/user-account.pen`). **OFF serves the legacy single-column page byte-identically.** See "Rollout" below. ## Panes | Pane | Change | |---|---| | Overview | Tier badge art in the Membership tile (links `/user/membership`); Standing replaces the creator-score figure; username renders its nameplate + badge cosmetics; identity card stacks on mobile | | Profile & Account | `ProfileCard` / `SocialProfileCard` flattened; Account standing shows the exact score; session refresh and delete are pointer rows, grouped | | Preferences | Regrouped to Media playback / Generation / File preferences / Features; image format moved to File preferences; assistant folded into Features | | Content & Browsing | Eye callout; mature-content rows; Topics as chips; hidden tags/users flattened | | Creator | Placement, remix and metric-visibility sections; sticker inventory pointer moved inside Stickers | | Membership & Billing | Subscription / payment methods / payouts flattened; gifts point at `/pricing/gift`; membership row stacks on mobile; empty states when the user has neither a membership nor a Creator Program payout config | | Security & Apps | Sign-in methods, API keys, OAuth apps, connected apps flattened; create buttons on the section heading | | Notifications | Delivery section; per-category icons; more room in an open category; `Other` sorted last | ## Decisions worth a reviewer's attention - **Cards take a `flat` prop rather than being forked.** The legacy page mounts the same components while the flag is alive; two copies of a settings form is how one of them silently loses a field. - **One rule per section.** Eight rows had nine dividers and read as a table. Rows are spaced instead. - **`/user/account/overview` is a new URL.** On mobile the index renders the section *menu*, so an overview reachable only at the index has no way in. `AccountLayout` takes `isIndex` from the route now; inferring it from `section.path` rendered the menu at both URLs. Covered by a test — deleting the alias 404s that URL. - **Standing thresholds moved to `accountStandingFromPoints`** (`strike.schema.ts`). Two surfaces show standing and it derives from active *points*, not the strike count. - **The sticker-inventory pointer survives its host section's bail paths.** It is not gated on placement, so nesting it inside that section would drop it whenever the placement controls cannot render (flag off, or a failed spaces read). - **`BrowsingCategories` switched to chips outright**, including the legacy card, rather than growing a variant prop — one rendering, no fork. - **First use of a Tailwind `has-[…]` variant in this repo** (`SettingRow`, to keep switch rows inline at every width). Tailwind is 3.4.17, so it is supported. - **Billing empty states are `flat`-only.** `SubscriptionCard` and `UserPaymentConfigurationCard` both returned `null` with nothing to show, which left the whole pane blank. They now offer the plans / the Creator Program instead — but only in the flat panes, so the legacy page keeps hiding them and stays byte-identical. Both reuse the metric-visibility upsell, extracted as `UpsellPanel`. ## Verification Typecheck clean; no new lint warnings. Covering suites green: `account-sections` (17), `strike.service` + `process-strikes` (75), the four Account browser suites (24), and the notification suites (79). `SettingsCard.earlyAdopter.browser` needed one assertion updated — it pinned the literal early-adopter copy. Kept its intent (the opt-in must explain itself) and split it into the promise and the caveat rather than loosening it. Walked every pane at 1440px and 390px as a subscribed Creator Program account, and the billing/notification changes on a free account with no Creator Program. ## Not in this PR - `designs/user-account.pen` has uncommitted local changes that predate this work; left alone deliberately. ## Rollout `accountSettingsV2` → Flipt key `account-settings-v2`. `availability: ['mod']` is the STATIC FALLBACK only — it decides nothing while Flipt answers, so it matters solely during a Flipt outage, where mods get the new shell and everyone else keeps the legacy page. The Flipt rollout is the on-switch: `account-settings-v2` is `enabled: false` with no rollouts today, so the page is off for everyone until a segment or threshold rollout is merged in `flipt-state`. Instant rollback = drop that rollout / set the threshold to 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ
217 lines
6.1 KiB
TypeScript
217 lines
6.1 KiB
TypeScript
import {
|
|
Autocomplete,
|
|
Badge,
|
|
Card,
|
|
Group,
|
|
Loader,
|
|
Portal,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
} from '@mantine/core';
|
|
import { useDebouncedValue } from '@mantine/hooks';
|
|
import { IconSearch, IconX } from '@tabler/icons-react';
|
|
import { useMemo, useRef, useState } from 'react';
|
|
import { LegacyActionIcon } from '~/components/LegacyActionIcon/LegacyActionIcon';
|
|
import { toHideableOptions } from '~/components/Account/hidden-users-options';
|
|
import { BasicMasonryGrid } from '~/components/MasonryGrid/BasicMasonryGrid';
|
|
import { useHiddenPreferencesData, useToggleHiddenPreferences } from '~/hooks/hidden-preferences';
|
|
import { SettingsSection } from '~/components/Account/SettingsLayout';
|
|
|
|
import { trpc } from '~/utils/trpc';
|
|
|
|
export function HiddenUsersSection({ flat }: { flat?: boolean } = {}) {
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const [search, setSearch] = useState('');
|
|
const [debouncedSearch] = useDebouncedValue(search, 300);
|
|
const [sort, setSort] = useState<string>('newest');
|
|
|
|
const { hiddenUsers, blockedUsers } = useHiddenPreferencesData();
|
|
|
|
const sortedHiddenUsers = useMemo(() => {
|
|
if (sort === 'newest') return hiddenUsers;
|
|
if (sort === 'oldest') return [...hiddenUsers].reverse();
|
|
|
|
const users = [...hiddenUsers];
|
|
if (sort === 'alphaAsc') {
|
|
return users.sort((a, b) =>
|
|
(a.username ?? '').localeCompare(b.username ?? '', undefined, { sensitivity: 'base' })
|
|
);
|
|
}
|
|
if (sort === 'alphaDesc') {
|
|
return users.sort((a, b) =>
|
|
(b.username ?? '').localeCompare(a.username ?? '', undefined, { sensitivity: 'base' })
|
|
);
|
|
}
|
|
|
|
return users;
|
|
}, [hiddenUsers, sort]);
|
|
|
|
const { data, isLoading, isFetching } = trpc.user.getAll.useQuery(
|
|
{ query: debouncedSearch.trim(), limit: 10 },
|
|
{ enabled: debouncedSearch !== '' }
|
|
);
|
|
const options = toHideableOptions(data, blockedUsers);
|
|
|
|
const toggleHiddenMutation = useToggleHiddenPreferences();
|
|
|
|
const handleToggleBlocked = async ({
|
|
id,
|
|
username,
|
|
}: {
|
|
id: number;
|
|
username?: string | null;
|
|
}) => {
|
|
await toggleHiddenMutation.mutateAsync({ kind: 'user', data: [{ id, username }] });
|
|
setSearch('');
|
|
};
|
|
|
|
const sortField = (
|
|
<Select
|
|
size="xs"
|
|
value={sort}
|
|
onChange={(val) => setSort(val ?? 'newest')}
|
|
data={[
|
|
{ label: 'Recently Added', value: 'newest' },
|
|
{ label: 'Oldest Added', value: 'oldest' },
|
|
{ label: 'A-Z', value: 'alphaAsc' },
|
|
{ label: 'Z-A', value: 'alphaDesc' },
|
|
]}
|
|
style={{ width: 160 }}
|
|
/>
|
|
);
|
|
|
|
const searchField = (
|
|
<Autocomplete
|
|
name="tag"
|
|
ref={searchInputRef}
|
|
placeholder="Search users to hide"
|
|
data={options}
|
|
value={search}
|
|
onChange={setSearch}
|
|
leftSection={isLoading && isFetching ? <Loader size="xs" /> : <IconSearch size={14} />}
|
|
onOptionSubmit={(value: string) => {
|
|
const { id } = options.find((x) => x.value === value) ?? {};
|
|
if (!id) return;
|
|
handleToggleBlocked({ id, username: value });
|
|
searchInputRef.current?.focus();
|
|
}}
|
|
/>
|
|
);
|
|
|
|
if (flat)
|
|
return (
|
|
<SettingsSection
|
|
title="Hidden users"
|
|
description="Their models, images and comments are hidden from you."
|
|
action={sortField}
|
|
>
|
|
<div className="flex flex-col gap-3">
|
|
{searchField}
|
|
<BasicMasonryGrid
|
|
items={sortedHiddenUsers}
|
|
render={UserBadge}
|
|
maxHeight={250}
|
|
columnGutter={4}
|
|
columnWidth={140}
|
|
/>
|
|
</div>
|
|
</SettingsSection>
|
|
);
|
|
|
|
return (
|
|
<Card withBorder>
|
|
<Card.Section withBorder inheritPadding py="xs">
|
|
<Group justify="space-between">
|
|
<Text fw={500}>Hidden Users</Text>
|
|
<Select
|
|
size="xs"
|
|
value={sort}
|
|
onChange={(val) => setSort(val ?? 'newest')}
|
|
data={[
|
|
{ label: 'Recently Added', value: 'newest' },
|
|
{ label: 'Oldest Added', value: 'oldest' },
|
|
{ label: 'A-Z', value: 'alphaAsc' },
|
|
{ label: 'Z-A', value: 'alphaDesc' },
|
|
]}
|
|
style={{ width: 160 }}
|
|
/>
|
|
</Group>
|
|
</Card.Section>
|
|
<Card.Section withBorder style={{ marginTop: -1 }}>
|
|
<Autocomplete
|
|
name="tag"
|
|
ref={searchInputRef}
|
|
placeholder="Search users to hide"
|
|
data={options}
|
|
value={search}
|
|
onChange={setSearch}
|
|
leftSection={isLoading && isFetching ? <Loader size="xs" /> : <IconSearch size={14} />}
|
|
onOptionSubmit={(value: string) => {
|
|
const { id } = options.find((x) => x.value === value) ?? {};
|
|
if (!id) return;
|
|
handleToggleBlocked({ id, username: value });
|
|
searchInputRef.current?.focus();
|
|
}}
|
|
variant="unstyled"
|
|
/>
|
|
</Card.Section>
|
|
<Card.Section inheritPadding py="md">
|
|
<Stack gap={5}>
|
|
<BasicMasonryGrid
|
|
items={sortedHiddenUsers}
|
|
render={UserBadge}
|
|
maxHeight={250}
|
|
columnGutter={4}
|
|
columnWidth={140}
|
|
/>
|
|
<Text c="dimmed" size="xs">
|
|
{`We'll hide content from these users throughout the site.`}
|
|
</Text>
|
|
</Stack>
|
|
</Card.Section>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function UserBadge({
|
|
data,
|
|
width,
|
|
}: {
|
|
data: { id: number; username?: string | null };
|
|
width: number;
|
|
}) {
|
|
const toggleHiddenMutation = useToggleHiddenPreferences();
|
|
|
|
const handleToggleBlocked = async ({
|
|
id,
|
|
username,
|
|
}: {
|
|
id: number;
|
|
username?: string | null;
|
|
}) => {
|
|
await toggleHiddenMutation.mutateAsync({ kind: 'user', data: [{ id, username }] });
|
|
};
|
|
|
|
return (
|
|
<Badge
|
|
key={data.id}
|
|
style={{ paddingRight: 3 }}
|
|
w={width}
|
|
rightSection={
|
|
<LegacyActionIcon
|
|
size="xs"
|
|
color="blue"
|
|
radius="xl"
|
|
variant="transparent"
|
|
onClick={() => handleToggleBlocked(data)}
|
|
>
|
|
<IconX size={10} />
|
|
</LegacyActionIcon>
|
|
}
|
|
>
|
|
{data.username ?? '[deleted]'}
|
|
</Badge>
|
|
);
|
|
}
|