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 { 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 +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"
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user