mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +00:00
♻️ refactor: share SettingsSection/Notice, PAGE_TITLE, simplify useAccountSettings
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export { CollapsibleCard } from "./CollapsibleCard";
|
||||
export { Modal } from "./Modal";
|
||||
export { Toggle } from "./Toggle";
|
||||
export { Section, Fact } from "./Section";
|
||||
export { SettingsSection, Notice } from "./SettingsSection";
|
||||
export { StatTile } from "./StatTile";
|
||||
export { SkeletonGrid } from "./SkeletonGrid";
|
||||
export { CardGrid } from "./CardGrid";
|
||||
@@ -26,4 +27,4 @@ export { Card } from "./Card";
|
||||
export { HoverImage } from "./HoverImage";
|
||||
export { ContextMenu } from "./ContextMenu";
|
||||
export type { ContextMenuEntry, ContextMenuItem } from "./ContextMenu";
|
||||
export { LABEL_HEADING } from "./styles";
|
||||
export { LABEL_HEADING, PAGE_TITLE } from "./styles";
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
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 { Trans } from "react-i18next";
|
||||
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 { DisplayNameSection } from "./sections/DisplayNameSection";
|
||||
import { EmailSection } from "./sections/EmailSection";
|
||||
@@ -32,11 +32,11 @@ export function AccountSettingsView() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const s = state.settings;
|
||||
const s = state.data;
|
||||
return (
|
||||
<div className={SHELL}>
|
||||
<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">
|
||||
<Trans
|
||||
i18nKey="account:subtitle"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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 { errorMessage } from "../../lib/api";
|
||||
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"];
|
||||
|
||||
@@ -14,39 +17,6 @@ export type SectionProps = {
|
||||
|
||||
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() {
|
||||
const { t } = useI18n();
|
||||
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({
|
||||
label,
|
||||
hint,
|
||||
|
||||
@@ -1,43 +1,36 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
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";
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ready"; settings: AccountSettings };
|
||||
|
||||
export function useAccountSettings(): {
|
||||
state: State;
|
||||
state: Async<AccountSettings>;
|
||||
reload: () => void;
|
||||
set: (s: AccountSettings) => void;
|
||||
} {
|
||||
const { status } = useAuth();
|
||||
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(() => {
|
||||
setState({ status: "loading" });
|
||||
api.settings
|
||||
.get()
|
||||
.then((settings) => setState({ status: "ready", settings }))
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
message: errorMessage(err, "Failed to load settings."),
|
||||
}),
|
||||
);
|
||||
setOverride(null);
|
||||
setNonce((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload, activeId]);
|
||||
|
||||
const set = useCallback(
|
||||
(settings: AccountSettings) => setState({ status: "ready", settings }),
|
||||
[],
|
||||
);
|
||||
const set = useCallback((settings: AccountSettings) => setOverride(settings), []);
|
||||
|
||||
return { state, reload, set };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from "../../../../shared/types/enhancements";
|
||||
import { api, errorMessage } from "../../lib/api";
|
||||
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";
|
||||
|
||||
@@ -62,7 +62,7 @@ export function EnhancementsView() {
|
||||
return (
|
||||
<div className={SHELL}>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useProfile } from "./useProfile";
|
||||
import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection";
|
||||
@@ -26,7 +26,7 @@ export function MyWorldsView() {
|
||||
return (
|
||||
<div className={SHELL}>
|
||||
<header>
|
||||
<h1 className="text-[26px] font-bold tracking-[-0.4px]">{t("nav:worlds")}</h1>
|
||||
<h1 className={PAGE_TITLE}>{t("nav:worlds")}</h1>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -3,7 +3,16 @@ import { Search as SearchIcon } from "lucide-react";
|
||||
import type { UserProfile } from "../../../../shared/types/user";
|
||||
import type { World } from "../../../../shared/types/world";
|
||||
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 { useNav } from "../navigation/NavContext";
|
||||
import { useUserMenu } from "../friends/useUserMenu";
|
||||
@@ -50,7 +59,7 @@ export function SearchView() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[760px] px-12 py-10">
|
||||
<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>
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -26,7 +26,14 @@ import type { UnityStatus } from "../../../../shared/types/unity";
|
||||
import { api, errorMessage } from "../../lib/api";
|
||||
import { useAsync } from "../../lib/useAsync";
|
||||
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 SECTIONS = "animate-rise flex flex-col gap-[18px]";
|
||||
@@ -39,7 +46,7 @@ export function SettingsView() {
|
||||
return (
|
||||
<div className={SHELL}>
|
||||
<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>
|
||||
</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 }[] = [
|
||||
{
|
||||
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"];
|
||||
|
||||
function RegionSection() {
|
||||
|
||||
Reference in New Issue
Block a user