♻️ refactor: auto format

This commit is contained in:
2026-06-30 21:47:49 +07:00
parent c93bfdccec
commit 2779b62f31
31 changed files with 484 additions and 351 deletions
+4 -1
View File
@@ -27,7 +27,10 @@ function normalizePreferences(raw: Partial<AppPreferences> | undefined): AppPref
const locale = raw?.locale;
const accent = raw?.accent?.trim() || null;
return {
schemeMode: schemeMode === "light" || schemeMode === "dark" || schemeMode === "auto" ? schemeMode : "auto",
schemeMode:
schemeMode === "light" || schemeMode === "dark" || schemeMode === "auto"
? schemeMode
: "auto",
accent: accent && /^#[\da-f]{6}$/i.test(accent) ? accent : null,
locale: isAppLocale(locale) ? locale : DEFAULT_LOCALE,
showDebugNav: raw?.showDebugNav === true,
+2 -1
View File
@@ -81,7 +81,8 @@ const handlers = {
"avatar:select": (avatarId) => guard(() => avatars.selectAvatar(avatarId)),
"avatar:update": ({ avatarId, edit }) => guard(() => avatars.updateAvatar(avatarId, edit)),
"avatar:delete": (avatarId) => guard(() => avatars.deleteAvatar(avatarId)),
"avatar:favorite": ({ avatarId, folder }) => guard(() => avatars.favoriteAvatar(avatarId, folder)),
"avatar:favorite": ({ avatarId, folder }) =>
guard(() => avatars.favoriteAvatar(avatarId, folder)),
"avatar:unfavorite": (avatarId) => guard(() => avatars.unfavoriteAvatar(avatarId)),
"avatar:reloadFavorites": () => guard(() => avatars.reloadFavorites()),
"avatar:moveFavorite": ({ avatarId, folder, reload }) =>
+5 -1
View File
@@ -305,7 +305,11 @@ async function restoreAvatarFavorite(
fav: { favoriteId: string; tags: string[] },
): Promise<void> {
await vrc.addFavorite({
body: { type: "avatar", favoriteId: fav.favoriteId, tags: fav.tags.length ? fav.tags : ["avatars1"] },
body: {
type: "avatar",
favoriteId: fav.favoriteId,
tags: fav.tags.length ? fav.tags : ["avatars1"],
},
throwOnError: true,
});
}
+5 -1
View File
@@ -54,7 +54,11 @@ export function toApiError(err: unknown): ApiError {
else if (status === undefined && /network|fetch|ENOTFOUND|ECONN/i.test(message)) code = "network";
if (status === 429) {
return { code, message: "VRChat API rate limit hit. Try again shortly.", retryAfter: retryAfterOf(e) };
return {
code,
message: "VRChat API rate limit hit. Try again shortly.",
retryAfter: retryAfterOf(e),
};
}
if (status !== undefined && status >= 500) {
return { code, message: "VRChat API is temporarily unavailable. Try again shortly." };
+15 -9
View File
@@ -260,7 +260,13 @@ function fillSlots(existing: FavoriteGroupInput[], caps: WorldFavoriteCaps): Fav
for (let i = 1; mine.length < max && i <= max; i++) {
const name = `${prefix}${i}`;
if (taken.has(name)) continue;
mine.push({ name, displayName: prettyFolderName(name), visibility: "private", worlds: [], vrcPlus });
mine.push({
name,
displayName: prettyFolderName(name),
visibility: "private",
worlds: [],
vrcPlus,
});
}
out.push(...mine);
}
@@ -354,10 +360,7 @@ export async function unfavoriteWorlds(worldIds: string[]): Promise<void> {
await reloadMyFavorites();
}
export async function moveWorldsToFolder(
worldIds: string[],
folder: string,
): Promise<MoveResult> {
export async function moveWorldsToFolder(worldIds: string[], folder: string): Promise<MoveResult> {
const vrc = requireActiveClient();
const me = await currentUser();
const records = await favoriteRecords(vrc);
@@ -458,10 +461,11 @@ async function favoriteTypeForExistingFolder(
query: { ownerId: userId, n: 100 },
throwOnError: true,
});
return (
data.find((g) => g.name === folder && isWorldGroupType(g.type))?.type ??
favoriteTypeForFolder(folder)
const group = data.find(
(g): g is (typeof data)[number] & { type: WorldFavoriteGroupType } =>
g.name === folder && isWorldGroupType(g.type),
);
return group?.type ?? favoriteTypeForFolder(folder);
}
type AddFavoriteBody = NonNullable<Parameters<VRChat["addFavorite"]>[0]>["body"];
@@ -487,7 +491,9 @@ async function favoriteRecordEntries(vrc: VRChat): Promise<WorldFavoriteRecord[]
query: { ownerId: me.id, n: 100 },
throwOnError: true,
});
const groups = rawGroups.filter((g) => isWorldGroupType(g.type));
const groups = rawGroups.filter((g): g is (typeof rawGroups)[number] & { type: WorldFavoriteGroupType } =>
isWorldGroupType(g.type),
);
const pageSize = 100;
const entries: WorldFavoriteRecord[] = [];
for (const group of groups) {
+3 -1
View File
@@ -23,7 +23,9 @@ export function CheckBox({
onChange();
}}
className={`flex size-5 items-center justify-center rounded-md border transition-colors ${
on ? "border-accent bg-accent text-on-accent" : "border-border bg-surface-2/80 hover:border-accent"
on
? "border-accent bg-accent text-on-accent"
: "border-border bg-surface-2/80 hover:border-accent"
}`}
>
{indeterminate ? <Minus size={13} /> : checked ? <Check size={13} /> : null}
@@ -15,15 +15,9 @@ export function useAccountSettings(): {
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 fetched = useAsync(() => api.settings.get(), [activeId, nonce], "Failed to load settings.");
const state: Async<AccountSettings> = override
? { status: "ready", data: override }
: fetched;
const state: Async<AccountSettings> = override ? { status: "ready", data: override } : fetched;
const reload = useCallback(() => {
setOverride(null);
@@ -26,7 +26,9 @@ export function AccountSwitcher() {
const status = self?.status ?? "offline";
const activeName = active ? (demoMode ? demoName(active.id) : active.displayName) : "Account";
const activeIcon = demoMode ? undefined : active?.userIcon;
const activeSub = demoMode ? statusMeta[status].label : self?.statusDescription || statusMeta[status].label;
const activeSub = demoMode
? statusMeta[status].label
: self?.statusDescription || statusMeta[status].label;
function close() {
setOpen(false);
@@ -57,9 +59,7 @@ export function AccountSwitcher() {
<span className="truncate text-left text-[13.5px] font-semibold leading-[1.25]">
{activeName}
</span>
<span className="truncate text-left text-[11px] font-medium text-faint">
{activeSub}
</span>
<span className="truncate text-left text-[11px] font-medium text-faint">{activeSub}</span>
</span>
<span
className={`acct__chevron shrink-0 text-faint transition-transform duration-[var(--dur)] ease-[var(--ease)] ${
@@ -58,7 +58,11 @@ export function AvatarActions({ avatar }: { avatar: Avatar }) {
</Button>
{isOwner ? (
<>
<Button variant="ghost" onClick={() => setEditOpen(true)} title={t("avatar:actions.edit")}>
<Button
variant="ghost"
onClick={() => setEditOpen(true)}
title={t("avatar:actions.edit")}
>
<Pencil size={15} />
</Button>
<Button
@@ -74,12 +78,14 @@ export function AvatarActions({ avatar }: { avatar: Avatar }) {
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
{favoriteOpen ? (
<FavoriteModal avatar={avatar} currentFolder={folder} onClose={() => setFavoriteOpen(false)} />
<FavoriteModal
avatar={avatar}
currentFolder={folder}
onClose={() => setFavoriteOpen(false)}
/>
) : null}
{editOpen ? (
<EditAvatarModal avatar={avatar} onClose={() => setEditOpen(false)} />
) : null}
{editOpen ? <EditAvatarModal avatar={avatar} onClose={() => setEditOpen(false)} /> : null}
<DeleteAvatarModal
avatar={avatar}
@@ -41,9 +41,7 @@ export function AvatarCard({
{selectable ? (
<span
className={`absolute left-1.5 top-1.5 flex size-5 items-center justify-center rounded-md border transition-colors ${
selected
? "border-accent bg-accent text-on-accent"
: "border-border bg-surface-2/80"
selected ? "border-accent bg-accent text-on-accent" : "border-border bg-surface-2/80"
}`}
>
{selected ? <Check size={13} /> : null}
@@ -17,7 +17,8 @@ export function AvatarView({ avatarId }: { avatarId: string }) {
const { avatar, failed } = useAvatar(avatarId);
if (avatar) return <AvatarDetail avatar={avatar} />;
if (failed) return <Banner className="m-10 max-w-[420px]">{t("avatar:detail.unavailable")}</Banner>;
if (failed)
return <Banner className="m-10 max-w-[420px]">{t("avatar:detail.unavailable")}</Banner>;
return <AvatarSkeleton />;
}
@@ -55,7 +56,9 @@ function AvatarDetail({ avatar }: { avatar: Avatar }) {
{avatar.releaseStatus !== "public" ? (
<Tag color="var(--status-ask)">{avatar.releaseStatus}</Tag>
) : null}
{avatar.featured ? <Tag color="var(--accent)">{t("avatar:detail.featured")}</Tag> : null}
{avatar.featured ? (
<Tag color="var(--accent)">{t("avatar:detail.featured")}</Tag>
) : null}
{avatar.platforms?.pc ? <Tag>PC</Tag> : null}
{avatar.platforms?.android ? <Tag color="var(--status-join)">Quest</Tag> : null}
</div>
@@ -126,7 +126,13 @@ function BulkBar({ ids, onDone }: { ids: string[]; onDone: () => void }) {
return (
<SelectionBar label={t("avatar:bulk.selected", { count: ids.length })}>
{busy ? (
<div className="selection-bar__progress" aria-label={t("avatar:bulk.unfavoritingProgress", { done: deleteProgress, total: ids.length })}>
<div
className="selection-bar__progress"
aria-label={t("avatar:bulk.unfavoritingProgress", {
done: deleteProgress,
total: ids.length,
})}
>
<span className="selection-bar__progress-label">
{t("avatar:bulk.unfavoritingProgress", { done: deleteProgress, total: ids.length })}
</span>
@@ -164,7 +170,10 @@ function BulkBar({ ids, onDone }: { ids: string[]; onDone: () => void }) {
{busy ? (
<div className="flex flex-col gap-1.5">
<span className="text-[12px] text-muted">
{t("avatar:bulk.unfavoritingProgress", { done: deleteProgress, total: ids.length })}
{t("avatar:bulk.unfavoritingProgress", {
done: deleteProgress,
total: ids.length,
})}
</span>
<div className="h-1 overflow-hidden rounded-full bg-surface-hover">
<div
@@ -205,8 +214,17 @@ function CurrentAvatarSection() {
const isOwner = avatar.authorId === self?.id;
const menuItems: ContextMenuEntry[] = [
{ label: t("avatar:context.open"), icon: <Eye size={14} />, onClick: () => nav.openAvatar(avatar.id) },
{ label: t("avatar:actions.wearing"), icon: <Shirt size={14} />, disabled: true, onClick: () => {} },
{
label: t("avatar:context.open"),
icon: <Eye size={14} />,
onClick: () => nav.openAvatar(avatar.id),
},
{
label: t("avatar:actions.wearing"),
icon: <Shirt size={14} />,
disabled: true,
onClick: () => {},
},
{ separator: true },
{
label: folder ? t("avatar:actions.manageFavorite") : t("avatar:actions.favorite"),
@@ -214,15 +232,19 @@ function CurrentAvatarSection() {
onClick: () => setFavoriteOpen(true),
},
...(isOwner
? [
{ label: t("avatar:actions.edit"), icon: <Pencil size={14} />, onClick: () => setEditOpen(true) },
? ([
{
label: t("avatar:actions.edit"),
icon: <Pencil size={14} />,
onClick: () => setEditOpen(true),
},
{
label: t("avatar:actions.delete"),
icon: <Trash2 size={14} />,
danger: true,
onClick: () => setDeleteOpen(true),
},
] satisfies ContextMenuEntry[]
] satisfies ContextMenuEntry[])
: []),
];
@@ -241,7 +263,11 @@ function CurrentAvatarSection() {
/>
</div>
{favoriteOpen ? (
<FavoriteModal avatar={avatar} currentFolder={folder} onClose={() => setFavoriteOpen(false)} />
<FavoriteModal
avatar={avatar}
currentFolder={folder}
onClose={() => setFavoriteOpen(false)}
/>
) : null}
{editOpen ? <EditAvatarModal avatar={avatar} onClose={() => setEditOpen(false)} /> : null}
<DeleteAvatarModal
@@ -266,7 +292,9 @@ function UploadedTab({ filter }: { filter: AvatarFilter }) {
const t = useT();
const nav = useNav();
const mine = useMyAvatars();
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; avatar: Avatar } | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; avatar: Avatar } | null>(
null,
);
const [editAvatar, setEditAvatar] = useState<Avatar | null>(null);
const [deleteAvatar, setDeleteAvatar] = useState<Avatar | null>(null);
@@ -276,9 +304,21 @@ function UploadedTab({ filter }: { filter: AvatarFilter }) {
if (!shown.length) return <p className="text-[13px] text-faint">{t("avatar:empty")}</p>;
const menuItems = (avatar: Avatar): ContextMenuEntry[] => [
{ label: t("avatar:context.open"), icon: <Eye size={14} />, onClick: () => nav.openAvatar(avatar.id) },
{ label: t("avatar:actions.wear"), icon: <Shirt size={14} />, onClick: () => void api.avatar.select(avatar.id) },
{ label: t("avatar:actions.edit"), icon: <Pencil size={14} />, onClick: () => setEditAvatar(avatar) },
{
label: t("avatar:context.open"),
icon: <Eye size={14} />,
onClick: () => nav.openAvatar(avatar.id),
},
{
label: t("avatar:actions.wear"),
icon: <Shirt size={14} />,
onClick: () => void api.avatar.select(avatar.id),
},
{
label: t("avatar:actions.edit"),
icon: <Pencil size={14} />,
onClick: () => setEditAvatar(avatar),
},
{ separator: true },
{
label: t("avatar:actions.delete"),
@@ -363,7 +403,9 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
setSelected(
new Set(
shown.flatMap((folder) =>
folder.avatars.filter((avatar) => avatar.releaseStatus === status).map((avatar) => avatar.id),
folder.avatars
.filter((avatar) => avatar.releaseStatus === status)
.map((avatar) => avatar.id),
),
),
);
@@ -387,7 +429,7 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
setSelected((s) => {
const next = new Set(s);
const all = ids.every((id) => next.has(id));
for (const id of ids) (all ? next.delete(id) : next.add(id));
for (const id of ids) all ? next.delete(id) : next.add(id);
return next;
});
@@ -398,9 +440,21 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
};
const menuItems = (avatar: Avatar, folder: string): ContextMenuEntry[] => [
{ label: t("avatar:context.open"), icon: <Eye size={14} />, onClick: () => nav.openAvatar(avatar.id) },
{ label: t("avatar:context.select"), icon: <CheckSquare size={14} />, onClick: () => selectOne(avatar.id) },
{ label: t("avatar:actions.wear"), icon: <Shirt size={14} />, onClick: () => void api.avatar.select(avatar.id) },
{
label: t("avatar:context.open"),
icon: <Eye size={14} />,
onClick: () => nav.openAvatar(avatar.id),
},
{
label: t("avatar:context.select"),
icon: <CheckSquare size={14} />,
onClick: () => selectOne(avatar.id),
},
{
label: t("avatar:actions.wear"),
icon: <Shirt size={14} />,
onClick: () => void api.avatar.select(avatar.id),
},
{ separator: true },
{
label: t("avatar:actions.manageFavorite"),
@@ -480,7 +534,12 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
? undefined
: (e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, avatar: a, folder: folder.name });
setContextMenu({
x: e.clientX,
y: e.clientY,
avatar: a,
folder: folder.name,
});
}
}
/>
@@ -529,9 +588,7 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
/>
) : null}
{selecting && selected.size > 0 ? (
<BulkBar ids={[...selected]} onDone={exitSelect} />
) : null}
{selecting && selected.size > 0 ? <BulkBar ids={[...selected]} onDone={exitSelect} /> : null}
</div>
);
}
@@ -65,7 +65,9 @@ export function FolderEditModal({
onChange={(e) => setDisplayName(e.target.value)}
/>
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-muted">{t("avatar:folder.visibility")}</span>
<span className="text-[12px] font-medium text-muted">
{t("avatar:folder.visibility")}
</span>
<select
className={INPUT_CLASS}
value={visibility}
@@ -40,11 +40,18 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const { buildItems, modal } = useUserMenu();
const openContextMenu = useCallback((e: React.MouseEvent, friend: UserProfile) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, friend: demoMode ? anonymizeUserForDemo(friend) : friend });
}, [demoMode]);
const openContextMenu = useCallback(
(e: React.MouseEvent, friend: UserProfile) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
friend: demoMode ? anonymizeUserForDemo(friend) : friend,
});
},
[demoMode],
);
return (
<aside className="friendsbar flex h-full flex-col overflow-hidden border-l border-border bg-surface">
@@ -56,7 +63,9 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
</header>
<div className="flex-1 overflow-y-auto p-2">
{selfAlone ? <FriendRow friend={self} onOpen={onOpen} isSelf selfChrome demoMode={demoMode} /> : null}
{selfAlone ? (
<FriendRow friend={self} onOpen={onOpen} isSelf selfChrome demoMode={demoMode} />
) : null}
{friends.length === 0 ? (
<p className="p-3 text-[13px] text-faint">{t("nav:friends.noFriendsOnline")}</p>
+166 -157
View File
@@ -65,7 +65,9 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
: presence.effective.label;
const avatar = avatarOf(displayProfile);
const banner = bannerOf(displayProfile);
const devLabel = displayProfile.developerType ? developerLabels[displayProfile.developerType] : undefined;
const devLabel = displayProfile.developerType
? developerLabels[displayProfile.developerType]
: undefined;
const bioLinks = (displayProfile.bioLinks ?? []).filter(Boolean);
const showcasedBadges = (displayProfile.badges ?? []).filter((b) => b.showcased);
@@ -87,7 +89,9 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
body={
<div className="min-w-0 flex-1 pb-2">
<div className="flex flex-wrap items-center gap-2.5">
<h2 className="text-[32px] font-bold tracking-[-0.6px]">{displayProfile.displayName}</h2>
<h2 className="text-[32px] font-bold tracking-[-0.6px]">
{displayProfile.displayName}
</h2>
{profile.isSelf ? <Tag color="var(--accent)">{t("profile:badge.you")}</Tag> : null}
{profile.isFriend && !profile.isSelf ? (
<Tag color="var(--status-join)">{t("profile:badge.friend")}</Tag>
@@ -141,173 +145,178 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
>
<LocationSection location={displayProfile.location} />
<Tabs
tabs={[
{ id: "overview", label: t("profile:tabs.overview") },
{ id: "worlds", label: t("profile:tabs.worlds") },
{ id: "favorites", label: t("profile:tabs.favorites") },
{ id: "groups", label: t("profile:tabs.groups") },
]}
active={tab}
onChange={setTab}
/>
<Tabs
tabs={[
{ id: "overview", label: t("profile:tabs.overview") },
{ id: "worlds", label: t("profile:tabs.worlds") },
{ id: "favorites", label: t("profile:tabs.favorites") },
{ id: "groups", label: t("profile:tabs.groups") },
]}
active={tab}
onChange={setTab}
/>
{tab === "overview" ? (
<div className="flex flex-col gap-5 rise-in">
{displayProfile.note ? (
<Section title={t("profile:sections.note")}>
<p className="text-[14px] leading-relaxed whitespace-pre-wrap text-text">
{displayProfile.note}
{tab === "overview" ? (
<div className="flex flex-col gap-5 rise-in">
{displayProfile.note ? (
<Section title={t("profile:sections.note")}>
<p className="text-[14px] leading-relaxed whitespace-pre-wrap text-text">
{displayProfile.note}
</p>
</Section>
) : null}
{displayProfile.statusDescription || displayProfile.bio || bioLinks.length ? (
<Section title={t("profile:sections.about")}>
{displayProfile.statusDescription ? (
<p className="text-[15px] italic text-text">{displayProfile.statusDescription}</p>
) : null}
{displayProfile.bio ? (
<p className="text-[14px] leading-relaxed whitespace-pre-wrap text-muted">
{displayProfile.bio}
</p>
</Section>
) : null}
{displayProfile.statusDescription || displayProfile.bio || bioLinks.length ? (
<Section title={t("profile:sections.about")}>
{displayProfile.statusDescription ? (
<p className="text-[15px] italic text-text">{displayProfile.statusDescription}</p>
) : null}
{displayProfile.bio ? (
<p className="text-[14px] leading-relaxed whitespace-pre-wrap text-muted">
{displayProfile.bio}
</p>
) : null}
{bioLinks.length ? (
<div className="flex flex-wrap gap-2">
{bioLinks.map((link) => (
<LinkPill key={link} href={link}>
{prettyLink(link)}
</LinkPill>
))}
</div>
) : null}
</Section>
) : null}
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2">
<Section title={t("profile:sections.details")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
{displayProfile.dateJoined ? (
<Fact
label={t("profile:facts.joined")}
value={formatDate(displayProfile.dateJoined)}
/>
) : null}
{displayProfile.lastLogin ? (
<Fact
label={t("profile:facts.lastLogin")}
value={formatDateTime(displayProfile.lastLogin)}
/>
) : null}
{displayProfile.lastActivity ? (
<Fact
label={t("profile:facts.lastActivity")}
value={formatDateTime(displayProfile.lastActivity)}
/>
) : null}
{displayProfile.lastPlatform ? (
<Fact
label={t("profile:facts.platform")}
value={platformLabel(displayProfile.lastPlatform)}
/>
) : null}
{displayProfile.state ? (
<Fact label={t("profile:facts.state")} value={stateLabel(displayProfile.state, t)} />
) : null}
{demoMode ? null : <Fact label={t("profile:facts.userId")} value={profile.id} mono />}
</dl>
</Section>
{displayProfile.languages?.length ? (
<Section title={t("profile:sections.languages")}>
<div className="flex flex-wrap gap-1.5">
{displayProfile.languages.map((code) => (
<Tag key={code}>{languageLabel(code)}</Tag>
))}
</div>
</Section>
) : null}
{displayProfile.currentAvatarTags?.length ? (
<Section title={t("profile:sections.avatarTags")}>
<div className="flex flex-wrap gap-1.5">
{displayProfile.currentAvatarTags.map((t) => (
<Tag key={t}>{prettyTag(t, "content_")}</Tag>
))}
</div>
</Section>
) : null}
{displayProfile.badges?.length ? (
<Section title={t("profile:sections.badges")}>
<div className="flex flex-wrap gap-2.5">
{[...displayProfile.badges]
.sort((a, b) => Number(b.showcased) - Number(a.showcased))
.map((b) => (
<div
key={b.id}
title={b.description}
className="flex items-center gap-2.5 rounded-lg border border-border bg-surface px-2.5 py-1.5"
>
{b.imageUrl ? (
<img
src={b.imageUrl}
alt=""
className="size-10 shrink-0 object-contain"
/>
) : null}
<span className="text-[13px] font-medium text-text">{b.name}</span>
</div>
))}
</div>
</Section>
) : null}
</div>
{displayProfile.pastDisplayNames?.length ? (
<Section title={t("profile:sections.formerNames")} collapsible>
{bioLinks.length ? (
<div className="flex flex-wrap gap-2">
{displayProfile.pastDisplayNames.map((p) => (
<span
key={`${p.displayName}-${p.updatedAt}`}
className="rounded-lg border border-border bg-surface px-2.5 py-1 text-[13px] text-text"
>
{p.displayName}
{p.updatedAt ? (
<em className="text-xs not-italic text-faint">
{" "}
· {t("profile:formerName.until", { date: formatDate(p.updatedAt) })}
</em>
) : null}
</span>
{bioLinks.map((link) => (
<LinkPill key={link} href={link}>
{prettyLink(link)}
</LinkPill>
))}
</div>
) : null}
</Section>
) : null}
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2">
<Section title={t("profile:sections.details")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
{displayProfile.dateJoined ? (
<Fact
label={t("profile:facts.joined")}
value={formatDate(displayProfile.dateJoined)}
/>
) : null}
{displayProfile.lastLogin ? (
<Fact
label={t("profile:facts.lastLogin")}
value={formatDateTime(displayProfile.lastLogin)}
/>
) : null}
{displayProfile.lastActivity ? (
<Fact
label={t("profile:facts.lastActivity")}
value={formatDateTime(displayProfile.lastActivity)}
/>
) : null}
{displayProfile.lastPlatform ? (
<Fact
label={t("profile:facts.platform")}
value={platformLabel(displayProfile.lastPlatform)}
/>
) : null}
{displayProfile.state ? (
<Fact
label={t("profile:facts.state")}
value={stateLabel(displayProfile.state, t)}
/>
) : null}
{demoMode ? null : (
<Fact label={t("profile:facts.userId")} value={profile.id} mono />
)}
</dl>
</Section>
{displayProfile.languages?.length ? (
<Section title={t("profile:sections.languages")}>
<div className="flex flex-wrap gap-1.5">
{displayProfile.languages.map((code) => (
<Tag key={code}>{languageLabel(code)}</Tag>
))}
</div>
</Section>
) : null}
</div>
) : null}
{tab === "worlds" ? (
<div className="rise-in">
<WorldSearch>
{(filter) => <WorldsSection userId={profile.id} filter={filter} />}
</WorldSearch>
</div>
) : null}
{displayProfile.currentAvatarTags?.length ? (
<Section title={t("profile:sections.avatarTags")}>
<div className="flex flex-wrap gap-1.5">
{displayProfile.currentAvatarTags.map((t) => (
<Tag key={t}>{prettyTag(t, "content_")}</Tag>
))}
</div>
</Section>
) : null}
{tab === "favorites" ? (
<div className="rise-in">
<WorldSearch>
{(filter) => <FavoriteWorldsSection userId={profile.id} filter={filter} />}
</WorldSearch>
{displayProfile.badges?.length ? (
<Section title={t("profile:sections.badges")}>
<div className="flex flex-wrap gap-2.5">
{[...displayProfile.badges]
.sort((a, b) => Number(b.showcased) - Number(a.showcased))
.map((b) => (
<div
key={b.id}
title={b.description}
className="flex items-center gap-2.5 rounded-lg border border-border bg-surface px-2.5 py-1.5"
>
{b.imageUrl ? (
<img
src={b.imageUrl}
alt=""
className="size-10 shrink-0 object-contain"
/>
) : null}
<span className="text-[13px] font-medium text-text">{b.name}</span>
</div>
))}
</div>
</Section>
) : null}
</div>
) : null}
{tab === "groups" ? (
<div className="rise-in">
<GroupsSection userId={profile.id} />
</div>
) : null}
{displayProfile.pastDisplayNames?.length ? (
<Section title={t("profile:sections.formerNames")} collapsible>
<div className="flex flex-wrap gap-2">
{displayProfile.pastDisplayNames.map((p) => (
<span
key={`${p.displayName}-${p.updatedAt}`}
className="rounded-lg border border-border bg-surface px-2.5 py-1 text-[13px] text-text"
>
{p.displayName}
{p.updatedAt ? (
<em className="text-xs not-italic text-faint">
{" "}
· {t("profile:formerName.until", { date: formatDate(p.updatedAt) })}
</em>
) : null}
</span>
))}
</div>
</Section>
) : null}
</div>
) : null}
{tab === "worlds" ? (
<div className="rise-in">
<WorldSearch>
{(filter) => <WorldsSection userId={profile.id} filter={filter} />}
</WorldSearch>
</div>
) : null}
{tab === "favorites" ? (
<div className="rise-in">
<WorldSearch>
{(filter) => <FavoriteWorldsSection userId={profile.id} filter={filter} />}
</WorldSearch>
</div>
) : null}
{tab === "groups" ? (
<div className="rise-in">
<GroupsSection userId={profile.id} />
</div>
) : null}
{menu ? (
<ContextMenu
@@ -38,7 +38,8 @@ export function useFavoriteWorlds(userId: string): {
const fallbackFolders = useMemo<FavoriteWorldFolder[]>(() => [], []);
const streamedFolders = streamed?.userId === userId ? streamed.folders : fallbackFolders;
const folders: FavoriteWorldFolder[] = fetched.status === "ready" ? fetched.data : streamedFolders;
const folders: FavoriteWorldFolder[] =
fetched.status === "ready" ? fetched.data : streamedFolders;
const ids = useMemo(() => folders.flatMap((f) => f.worldIds), [folders]);
const worlds = useWorlds(useShallow((s) => ids.map((id) => s.worlds[id]).filter(Boolean)));
@@ -347,10 +347,7 @@ function GameSection() {
onChange={(e) => setPath(e.target.value)}
/>
</div>
<Button
variant="ghost"
onClick={() => void run(pickGamePath(), t("settings:game.saved"))}
>
<Button variant="ghost" onClick={() => void run(pickGamePath(), t("settings:game.saved"))}>
<FolderOpen size={14} />
{t("settings:game.browse")}
</Button>
@@ -380,9 +377,7 @@ function GameSection() {
</div>
<div className="mt-3 flex flex-wrap items-center gap-2.5">
<Button
onClick={() =>
void run(setGamePath(path.trim() || null), t("settings:game.saved"))
}
onClick={() => void run(setGamePath(path.trim() || null), t("settings:game.saved"))}
loading={busy}
disabled={!dirty}
>
@@ -102,7 +102,7 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
setSelected((s) => {
const next = new Set(s);
const all = ids.every((id) => next.has(id));
for (const id of ids) (all ? next.delete(id) : next.add(id));
for (const id of ids) all ? next.delete(id) : next.add(id);
return next;
});
@@ -118,7 +118,11 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
};
const menuItems = (world: World, folder: string): ContextMenuEntry[] => [
{ label: t("world:context.open"), icon: <Eye size={14} />, onClick: () => nav.openWorld(world.id) },
{
label: t("world:context.open"),
icon: <Eye size={14} />,
onClick: () => nav.openWorld(world.id),
},
{
label: t("world:context.select"),
icon: <CheckSquare size={14} />,
@@ -144,7 +148,10 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
<div className="flex flex-wrap justify-end gap-2">
{selecting ? (
<>
<Button variant="ghost" onClick={() => selectWhere((w) => w.releaseStatus === "private")}>
<Button
variant="ghost"
onClick={() => selectWhere((w) => w.releaseStatus === "private")}
>
{t("world:bulk.selectPrivate")}
</Button>
{hasDeleted ? (
@@ -201,7 +208,12 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
? undefined
: (e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, world: w, folder: folder.name });
setContextMenu({
x: e.clientX,
y: e.clientY,
world: w,
folder: folder.name,
});
}
}
/>
@@ -29,7 +29,11 @@ export function WorldCard({
return (
<Card
onClick={
selectable ? onToggleSelect : world.deleted ? undefined : (onOpen ?? (() => openWorld(world.id)))
selectable
? onToggleSelect
: world.deleted
? undefined
: (onOpen ?? (() => openWorld(world.id)))
}
onContextMenu={onContextMenu}
className={`${selected ? "outline outline-[3px] -outline-offset-[3px] outline-accent" : ""} ${
+105 -94
View File
@@ -83,110 +83,121 @@ function WorldCard({ world }: { world: World }) {
</div>
}
>
<CreateInstanceModal worldId={world.id} open={createOpen} onClose={() => setCreateOpen(false)} />
<CreateInstanceModal
worldId={world.id}
open={createOpen}
onClose={() => setCreateOpen(false)}
/>
{favoriteOpen ? (
<WorldFavoriteModal world={world} currentFolder={folder} onClose={() => setFavoriteOpen(false)} />
<WorldFavoriteModal
world={world}
currentFolder={folder}
onClose={() => setFavoriteOpen(false)}
/>
) : null}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile
icon={<Users size={15} />}
label={t("world:detail.stats.playersNow")}
value={compactNumber(players)}
live={players > 0}
/>
<StatTile
icon={<Heart size={15} />}
label={t("world:detail.stats.favorites")}
value={compactNumber(world.favorites)}
/>
<StatTile
icon={<Globe size={15} />}
label={t("world:detail.stats.visits")}
value={world.visits ? compactNumber(world.visits) : "—"}
/>
<StatTile
icon={<Users size={15} />}
label={t("world:detail.stats.capacity")}
value={`${world.capacity}`}
/>
</div>
<StatTile
icon={<Users size={15} />}
label={t("world:detail.stats.playersNow")}
value={compactNumber(players)}
live={players > 0}
/>
<StatTile
icon={<Heart size={15} />}
label={t("world:detail.stats.favorites")}
value={compactNumber(world.favorites)}
/>
<StatTile
icon={<Globe size={15} />}
label={t("world:detail.stats.visits")}
value={world.visits ? compactNumber(world.visits) : "—"}
/>
<StatTile
icon={<Users size={15} />}
label={t("world:detail.stats.capacity")}
value={`${world.capacity}`}
/>
</div>
{world.description ? (
<Section title={t("world:detail.sections.description")}>
<p className="whitespace-pre-wrap text-[14px] leading-relaxed text-muted">
{world.description}
</p>
</Section>
) : null}
{world.description ? (
<Section title={t("world:detail.sections.description")}>
<p className="whitespace-pre-wrap text-[14px] leading-relaxed text-muted">
{world.description}
</p>
</Section>
) : null}
{world.previewYoutubeId ? (
<Section title={t("world:detail.sections.trailer")}>
<div className="aspect-video overflow-hidden rounded-lg border border-border">
<iframe
className="size-full"
src={`https://www.youtube.com/embed/${world.previewYoutubeId}`}
title={t("world:detail.sections.trailer")}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
{world.previewYoutubeId ? (
<Section title={t("world:detail.sections.trailer")}>
<div className="aspect-video overflow-hidden rounded-lg border border-border">
<iframe
className="size-full"
src={`https://www.youtube.com/embed/${world.previewYoutubeId}`}
title={t("world:detail.sections.trailer")}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</Section>
) : null}
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2">
<Section title={t("world:detail.sections.details")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
{world.recommendedCapacity ? (
<Fact
label={t("world:detail.facts.recommended")}
value={t("world:detail.recommendedValue", { count: world.recommendedCapacity })}
/>
) : null}
{typeof world.popularity === "number" ? (
<Fact label={t("world:detail.facts.popularity")} value={`${world.popularity} / 6`} />
) : null}
{typeof world.heat === "number" ? (
<Fact label={t("world:detail.facts.heat")} value={`${world.heat} / 6`} />
) : null}
{world.version ? (
<Fact label={t("world:detail.facts.version")} value={`${world.version}`} />
) : null}
{world.publishedAt ? (
<Fact
label={t("world:detail.facts.published")}
value={formatDate(world.publishedAt)}
/>
) : null}
{world.labsPublishedAt ? (
<Fact
label={t("world:detail.facts.communityLabs")}
value={formatDate(world.labsPublishedAt)}
/>
) : null}
{world.createdAt ? (
<Fact label={t("world:detail.facts.created")} value={formatDate(world.createdAt)} />
) : null}
{world.updatedAt ? (
<Fact label={t("world:detail.facts.updated")} value={formatDate(world.updatedAt)} />
) : null}
<Fact label={t("world:detail.facts.worldId")} value={world.id} mono />
</dl>
</Section>
{world.tags.length ? (
<Section title={t("world:detail.sections.tags")}>
<div className="flex flex-wrap gap-1.5">
{tagsWithPrefix(world.tags, "author_tag_").length ? (
tagsWithPrefix(world.tags, "author_tag_").map((tag) => (
<Tag key={tag}>{prettyTag(tag, "author_tag_")}</Tag>
))
) : (
<span className="inline-flex items-center gap-1.5 text-[13px] text-faint">
<TagIcon size={13} /> {t("world:detail.noAuthorTags")}
</span>
)}
</div>
</Section>
) : null}
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2">
<Section title={t("world:detail.sections.details")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
{world.recommendedCapacity ? (
<Fact
label={t("world:detail.facts.recommended")}
value={t("world:detail.recommendedValue", { count: world.recommendedCapacity })}
/>
) : null}
{typeof world.popularity === "number" ? (
<Fact label={t("world:detail.facts.popularity")} value={`${world.popularity} / 6`} />
) : null}
{typeof world.heat === "number" ? (
<Fact label={t("world:detail.facts.heat")} value={`${world.heat} / 6`} />
) : null}
{world.version ? (
<Fact label={t("world:detail.facts.version")} value={`${world.version}`} />
) : null}
{world.publishedAt ? (
<Fact label={t("world:detail.facts.published")} value={formatDate(world.publishedAt)} />
) : null}
{world.labsPublishedAt ? (
<Fact
label={t("world:detail.facts.communityLabs")}
value={formatDate(world.labsPublishedAt)}
/>
) : null}
{world.createdAt ? (
<Fact label={t("world:detail.facts.created")} value={formatDate(world.createdAt)} />
) : null}
{world.updatedAt ? (
<Fact label={t("world:detail.facts.updated")} value={formatDate(world.updatedAt)} />
) : null}
<Fact label={t("world:detail.facts.worldId")} value={world.id} mono />
</dl>
</Section>
{world.tags.length ? (
<Section title={t("world:detail.sections.tags")}>
<div className="flex flex-wrap gap-1.5">
{tagsWithPrefix(world.tags, "author_tag_").length ? (
tagsWithPrefix(world.tags, "author_tag_").map((tag) => (
<Tag key={tag}>{prettyTag(tag, "author_tag_")}</Tag>
))
) : (
<span className="inline-flex items-center gap-1.5 text-[13px] text-faint">
<TagIcon size={13} /> {t("world:detail.noAuthorTags")}
</span>
)}
</div>
</Section>
) : null}
</div>
</div>
</HeroHeader>
);
}
+9 -1
View File
@@ -1,4 +1,12 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { AppConfig, AppPreferences, PreferredRegion } from "../../../shared/types/appConfig";
import { api } from "./api";
+8 -5
View File
@@ -45,11 +45,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [accent, setAccentState] = useState<string | null>(null);
const lastApplied = useRef<string>("");
const setAccent = useCallback((hex: string | null) => {
applyAccent(hex);
setAccentState(hex);
void setPreferences({ accent: hex });
}, [setPreferences]);
const setAccent = useCallback(
(hex: string | null) => {
applyAccent(hex);
setAccentState(hex);
void setPreferences({ accent: hex });
},
[setPreferences],
);
const setTheme = useCallback(
(id: string) => {
+7 -2
View File
@@ -24,7 +24,11 @@ export function errorMessage(err: unknown, fallback: string): string {
export function isRetryableApiError(err: unknown): boolean {
if (!(err instanceof ApiException)) return false;
return err.error.code === "unknown" || err.error.code === "network" || err.error.code === "rate_limited";
return (
err.error.code === "unknown" ||
err.error.code === "network" ||
err.error.code === "rate_limited"
);
}
type DataOf<R> = R extends { ok: true; data: infer D } ? D : never;
@@ -163,7 +167,8 @@ export const api = {
game: {
status: () => call("game:status"),
launch: () => call("game:launch"),
join: (location: string, shortName?: string | null) => call("game:join", { location, shortName }),
join: (location: string, shortName?: string | null) =>
call("game:join", { location, shortName }),
openProtocol: (url: string) => call("game:openProtocol", url),
},
gallery: {
+1 -7
View File
@@ -1,9 +1,3 @@
export { I18nProvider, useI18n, useT } from "./I18nContext";
export {
DEFAULT_LOCALE,
NAMESPACES,
availableLocales,
getLocale,
i18n,
} from "./registry";
export { DEFAULT_LOCALE, NAMESPACES, availableLocales, getLocale, i18n } from "./registry";
export type { Locale, LocaleCode, LocaleMeta, Messages } from "./types";
@@ -102,4 +102,4 @@
"avatarId": "Avatar ID"
}
}
}
}
@@ -137,4 +137,4 @@
"worldDetails": "View world details",
"instanceId": "Instance ID"
}
}
}
@@ -102,4 +102,4 @@
"avatarId": "アバターID"
}
}
}
}
@@ -137,4 +137,4 @@
"worldDetails": "ワールドの詳細を見る",
"instanceId": "インスタンスID"
}
}
}
@@ -102,4 +102,4 @@
"avatarId": "ไอดีอวตาร"
}
}
}
}
+1 -2
View File
@@ -69,8 +69,7 @@ function toFolder(
};
}
export const useWorldFavoriteLimits = (): FavoriteLimits =>
useWorldFavorites((s) => s.limits);
export const useWorldFavoriteLimits = (): FavoriteLimits => useWorldFavorites((s) => s.limits);
export const useWorldFavoritesLoaded = (): boolean => useWorldFavorites((s) => s.loaded);
+4 -1
View File
@@ -109,7 +109,10 @@ export interface IpcRequests {
"avatar:unfavoriteMany": (avatarIds: string[]) => IpcResult<void>;
"avatar:moveFavoriteMany": (p: { avatarIds: string[]; folder: string }) => IpcResult<MoveResult>;
"avatar:clearFavoriteFolder": (folder: string) => IpcResult<void>;
"avatar:updateFavoriteFolder": (p: { folder: string; edit: FavoriteGroupEdit }) => IpcResult<void>;
"avatar:updateFavoriteFolder": (p: {
folder: string;
edit: FavoriteGroupEdit;
}) => IpcResult<void>;
"group:byUser": (userId: string) => IpcResult<Group[]>;
"group:represented": (userId: string) => IpcResult<Group | null>;