♻️ refactor: share SettingsSection/Notice, PAGE_TITLE, simplify useAccountSettings

This commit is contained in:
2026-06-29 15:29:09 +07:00
parent d54972c84c
commit e575f99116
10 changed files with 106 additions and 121 deletions
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import { Check, X } from "lucide-react";
export function SettingsSection({
title,
description,
icon,
danger,
children,
}: {
title: string;
description?: ReactNode;
icon?: ReactNode;
danger?: boolean;
children: ReactNode;
}) {
return (
<section
className={`rounded-DEFAULT border px-6 py-[22px] shadow-[var(--shadow-1)] ${
danger
? "border-[color-mix(in_srgb,var(--danger)_35%,var(--border))] bg-[color-mix(in_srgb,var(--danger)_6%,var(--surface-2))]"
: "border-border bg-surface-2"
}`}
>
<div className="mb-4">
<h2 className="flex items-center gap-2 text-[15px] font-bold">
{icon ? <span className="text-muted">{icon}</span> : null}
{title}
</h2>
{description ? <p className="mt-1 text-[13px] text-muted">{description}</p> : null}
</div>
{children}
</section>
);
}
export function Notice({ error, ok }: { error?: string | null; ok?: string | null }) {
if (error)
return (
<p className="mt-3 flex items-center gap-1.5 text-[12.5px] text-[var(--danger)]">
<X size={14} /> {error}
</p>
);
if (ok)
return (
<p className="mt-3 flex items-center gap-1.5 text-[12.5px] text-[var(--status-active)]">
<Check size={14} /> {ok}
</p>
);
return null;
}
+2 -1
View File
@@ -15,6 +15,7 @@ export { CollapsibleCard } from "./CollapsibleCard";
export { Modal } from "./Modal"; export { Modal } from "./Modal";
export { Toggle } from "./Toggle"; export { Toggle } from "./Toggle";
export { Section, Fact } from "./Section"; export { Section, Fact } from "./Section";
export { SettingsSection, Notice } from "./SettingsSection";
export { StatTile } from "./StatTile"; export { StatTile } from "./StatTile";
export { SkeletonGrid } from "./SkeletonGrid"; export { SkeletonGrid } from "./SkeletonGrid";
export { CardGrid } from "./CardGrid"; export { CardGrid } from "./CardGrid";
@@ -26,4 +27,4 @@ export { Card } from "./Card";
export { HoverImage } from "./HoverImage"; export { HoverImage } from "./HoverImage";
export { ContextMenu } from "./ContextMenu"; export { ContextMenu } from "./ContextMenu";
export type { ContextMenuEntry, ContextMenuItem } from "./ContextMenu"; export type { ContextMenuEntry, ContextMenuItem } from "./ContextMenu";
export { LABEL_HEADING } from "./styles"; export { LABEL_HEADING, PAGE_TITLE } from "./styles";
+1
View File
@@ -1 +1,2 @@
export const LABEL_HEADING = "text-[11px] font-semibold uppercase tracking-wide text-faint"; export const LABEL_HEADING = "text-[11px] font-semibold uppercase tracking-wide text-faint";
export const PAGE_TITLE = "text-[26px] font-bold tracking-[-0.4px]";
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { useI18n } from "../../lib/i18n"; import { useI18n } from "../../lib/i18n";
import { Banner, Loader, Tabs } from "../../components/ui"; import { Banner, Loader, PAGE_TITLE, Tabs } from "../../components/ui";
import { useAccountSettings } from "./useAccountSettings"; import { useAccountSettings } from "./useAccountSettings";
import { DisplayNameSection } from "./sections/DisplayNameSection"; import { DisplayNameSection } from "./sections/DisplayNameSection";
import { EmailSection } from "./sections/EmailSection"; import { EmailSection } from "./sections/EmailSection";
@@ -32,11 +32,11 @@ export function AccountSettingsView() {
</div> </div>
); );
const s = state.settings; const s = state.data;
return ( return (
<div className={SHELL}> <div className={SHELL}>
<header> <header>
<h1 className="text-[26px] font-bold tracking-[-0.4px]">{t("account:title")}</h1> <h1 className={PAGE_TITLE}>{t("account:title")}</h1>
<p className="mt-1 text-[13.5px] text-muted"> <p className="mt-1 text-[13.5px] text-muted">
<Trans <Trans
i18nKey="account:subtitle" i18nKey="account:subtitle"
+5 -51
View File
@@ -1,9 +1,12 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { Check, ExternalLink, X } from "lucide-react"; import { ExternalLink } from "lucide-react";
import type { AccountSettings } from "../../../../shared/types/settings"; import type { AccountSettings } from "../../../../shared/types/settings";
import { errorMessage } from "../../lib/api"; import { errorMessage } from "../../lib/api";
import { useI18n } from "../../lib/i18n"; import { useI18n } from "../../lib/i18n";
import { Toggle } from "../../components/ui"; import { SettingsSection as Section, Toggle } from "../../components/ui";
export { Section };
export { Notice } from "../../components/ui";
export type TFunc = ReturnType<typeof useI18n>["t"]; export type TFunc = ReturnType<typeof useI18n>["t"];
@@ -14,39 +17,6 @@ export type SectionProps = {
export const WEBSITE_ACCOUNT = "https://vrchat.com/home/profile"; export const WEBSITE_ACCOUNT = "https://vrchat.com/home/profile";
export function Section({
title,
description,
icon,
danger,
children,
}: {
title: string;
description?: ReactNode;
icon?: ReactNode;
danger?: boolean;
children: ReactNode;
}) {
return (
<section
className={`rounded-DEFAULT border px-6 py-[22px] shadow-[var(--shadow-1)] ${
danger
? "border-[color-mix(in_srgb,var(--danger)_35%,var(--border))] bg-[color-mix(in_srgb,var(--danger)_6%,var(--surface-2))]"
: "border-border bg-surface-2"
}`}
>
<div className="mb-4">
<h2 className="flex items-center gap-2 text-[15px] font-bold">
{icon ? <span className="text-muted">{icon}</span> : null}
{title}
</h2>
{description ? <p className="mt-1 text-[13px] text-muted">{description}</p> : null}
</div>
{children}
</section>
);
}
export function useAction() { export function useAction() {
const { t } = useI18n(); const { t } = useI18n();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -80,22 +50,6 @@ export function useAction() {
}; };
} }
export function Notice({ error, ok }: { error?: string | null; ok?: string | null }) {
if (error)
return (
<p className="mt-3 flex items-center gap-1.5 text-[12.5px] text-[var(--danger)]">
<X size={14} /> {error}
</p>
);
if (ok)
return (
<p className="mt-3 flex items-center gap-1.5 text-[12.5px] text-[var(--status-active)]">
<Check size={14} /> {ok}
</p>
);
return null;
}
export function ToggleRow({ export function ToggleRow({
label, label,
hint, hint,
@@ -1,43 +1,36 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import type { AccountSettings } from "../../../../shared/types/settings"; import type { AccountSettings } from "../../../../shared/types/settings";
import { api, errorMessage } from "../../lib/api"; import { api } from "../../lib/api";
import { useAsync, type Async } from "../../lib/useAsync";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
type State =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; settings: AccountSettings };
export function useAccountSettings(): { export function useAccountSettings(): {
state: State; state: Async<AccountSettings>;
reload: () => void; reload: () => void;
set: (s: AccountSettings) => void; set: (s: AccountSettings) => void;
} { } {
const { status } = useAuth(); const { status } = useAuth();
const activeId = status.state === "authenticated" ? status.user.id : null; const activeId = status.state === "authenticated" ? status.user.id : null;
const [state, setState] = useState<State>({ status: "loading" });
const [nonce, setNonce] = useState(0);
const [override, setOverride] = useState<AccountSettings | null>(null);
const fetched = useAsync(
() => api.settings.get(),
[activeId, nonce],
"Failed to load settings.",
);
const state: Async<AccountSettings> = override
? { status: "ready", data: override }
: fetched;
const reload = useCallback(() => { const reload = useCallback(() => {
setState({ status: "loading" }); setOverride(null);
api.settings setNonce((n) => n + 1);
.get()
.then((settings) => setState({ status: "ready", settings }))
.catch((err) =>
setState({
status: "error",
message: errorMessage(err, "Failed to load settings."),
}),
);
}, []); }, []);
useEffect(() => { const set = useCallback((settings: AccountSettings) => setOverride(settings), []);
reload();
}, [reload, activeId]);
const set = useCallback(
(settings: AccountSettings) => setState({ status: "ready", settings }),
[],
);
return { state, reload, set }; return { state, reload, set };
} }
@@ -8,7 +8,7 @@ import type {
} from "../../../../shared/types/enhancements"; } from "../../../../shared/types/enhancements";
import { api, errorMessage } from "../../lib/api"; import { api, errorMessage } from "../../lib/api";
import { useI18n } from "../../lib/i18n"; import { useI18n } from "../../lib/i18n";
import { Badge, Banner, Loader, Toggle } from "../../components/ui"; import { Badge, Banner, Loader, PAGE_TITLE, Toggle } from "../../components/ui";
const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10"; const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10";
@@ -62,7 +62,7 @@ export function EnhancementsView() {
return ( return (
<div className={SHELL}> <div className={SHELL}>
<header> <header>
<h1 className="text-[26px] font-bold tracking-[-0.4px]">{t("enhancements:title")}</h1> <h1 className={PAGE_TITLE}>{t("enhancements:title")}</h1>
<p className="mt-1 text-[13.5px] text-muted">{t("enhancements:subtitle")}</p> <p className="mt-1 text-[13.5px] text-muted">{t("enhancements:subtitle")}</p>
</header> </header>
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Banner, Loader, Tabs } from "../../components/ui"; import { Banner, Loader, PAGE_TITLE, Tabs } from "../../components/ui";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { useProfile } from "./useProfile"; import { useProfile } from "./useProfile";
import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection"; import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection";
@@ -26,7 +26,7 @@ export function MyWorldsView() {
return ( return (
<div className={SHELL}> <div className={SHELL}>
<header> <header>
<h1 className="text-[26px] font-bold tracking-[-0.4px]">{t("nav:worlds")}</h1> <h1 className={PAGE_TITLE}>{t("nav:worlds")}</h1>
</header> </header>
<Tabs <Tabs
@@ -3,7 +3,16 @@ import { Search as SearchIcon } from "lucide-react";
import type { UserProfile } from "../../../../shared/types/user"; import type { UserProfile } from "../../../../shared/types/user";
import type { World } from "../../../../shared/types/world"; import type { World } from "../../../../shared/types/world";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { Avatar, Button, CardGrid, ContextMenu, Field, Tabs, Tag } from "../../components/ui"; import {
Avatar,
Button,
CardGrid,
ContextMenu,
Field,
PAGE_TITLE,
Tabs,
Tag,
} from "../../components/ui";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { useNav } from "../navigation/NavContext"; import { useNav } from "../navigation/NavContext";
import { useUserMenu } from "../friends/useUserMenu"; import { useUserMenu } from "../friends/useUserMenu";
@@ -50,7 +59,7 @@ export function SearchView() {
return ( return (
<div className="mx-auto w-full max-w-[760px] px-12 py-10"> <div className="mx-auto w-full max-w-[760px] px-12 py-10">
<header className="mb-5"> <header className="mb-5">
<h1 className="text-[26px] font-bold tracking-[-0.4px]">{t("search:title")}</h1> <h1 className={PAGE_TITLE}>{t("search:title")}</h1>
</header> </header>
<Tabs <Tabs
@@ -26,7 +26,14 @@ import type { UnityStatus } from "../../../../shared/types/unity";
import { api, errorMessage } from "../../lib/api"; import { api, errorMessage } from "../../lib/api";
import { useAsync } from "../../lib/useAsync"; import { useAsync } from "../../lib/useAsync";
import { regionFlag, regionLabel } from "../../lib/vrchat"; import { regionFlag, regionLabel } from "../../lib/vrchat";
import { Button, Field, Tabs } from "../../components/ui"; import {
Button,
Field,
Notice,
PAGE_TITLE,
SettingsSection as Section,
Tabs,
} from "../../components/ui";
const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10"; const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10";
const SECTIONS = "animate-rise flex flex-col gap-[18px]"; const SECTIONS = "animate-rise flex flex-col gap-[18px]";
@@ -39,7 +46,7 @@ export function SettingsView() {
return ( return (
<div className={SHELL}> <div className={SHELL}>
<header> <header>
<h1 className="text-[26px] font-bold tracking-[-0.4px]">{t("settings:title")}</h1> <h1 className={PAGE_TITLE}>{t("settings:title")}</h1>
<p className="mt-1 text-[13.5px] text-muted">{t("settings:subtitle")}</p> <p className="mt-1 text-[13.5px] text-muted">{t("settings:subtitle")}</p>
</header> </header>
@@ -84,31 +91,6 @@ export function SettingsView() {
); );
} }
function Section({
title,
description,
icon,
children,
}: {
title: string;
description?: ReactNode;
icon?: ReactNode;
children: ReactNode;
}) {
return (
<section className="rounded-DEFAULT border border-border bg-surface-2 px-6 py-[22px] shadow-[var(--shadow-1)]">
<div className="mb-4">
<h2 className="flex items-center gap-2 text-[15px] font-bold">
{icon ? <span className="text-muted">{icon}</span> : null}
{title}
</h2>
{description ? <p className="mt-1 text-[13px] text-muted">{description}</p> : null}
</div>
{children}
</section>
);
}
const SCHEME_OPTIONS: { mode: SchemeMode; labelKey: string; hintKey: string; icon: ReactNode }[] = [ const SCHEME_OPTIONS: { mode: SchemeMode; labelKey: string; hintKey: string; icon: ReactNode }[] = [
{ {
mode: "auto", mode: "auto",
@@ -378,12 +360,6 @@ function GameSection() {
); );
} }
function Notice({ error, ok }: { error?: string | null; ok?: string | null }) {
if (error) return <p className="mt-3 text-[12.5px] text-[var(--danger)]">{error}</p>;
if (ok) return <p className="mt-3 text-[12.5px] text-[var(--status-active)]">{ok}</p>;
return null;
}
const REGION_OPTIONS: PreferredRegion[] = ["auto", "us", "use", "eu", "jp"]; const REGION_OPTIONS: PreferredRegion[] = ["auto", "us", "use", "eu", "jp"];
function RegionSection() { function RegionSection() {