♻️ 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 locale = raw?.locale;
const accent = raw?.accent?.trim() || null; const accent = raw?.accent?.trim() || null;
return { 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, accent: accent && /^#[\da-f]{6}$/i.test(accent) ? accent : null,
locale: isAppLocale(locale) ? locale : DEFAULT_LOCALE, locale: isAppLocale(locale) ? locale : DEFAULT_LOCALE,
showDebugNav: raw?.showDebugNav === true, showDebugNav: raw?.showDebugNav === true,
+2 -1
View File
@@ -81,7 +81,8 @@ const handlers = {
"avatar:select": (avatarId) => guard(() => avatars.selectAvatar(avatarId)), "avatar:select": (avatarId) => guard(() => avatars.selectAvatar(avatarId)),
"avatar:update": ({ avatarId, edit }) => guard(() => avatars.updateAvatar(avatarId, edit)), "avatar:update": ({ avatarId, edit }) => guard(() => avatars.updateAvatar(avatarId, edit)),
"avatar:delete": (avatarId) => guard(() => avatars.deleteAvatar(avatarId)), "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:unfavorite": (avatarId) => guard(() => avatars.unfavoriteAvatar(avatarId)),
"avatar:reloadFavorites": () => guard(() => avatars.reloadFavorites()), "avatar:reloadFavorites": () => guard(() => avatars.reloadFavorites()),
"avatar:moveFavorite": ({ avatarId, folder, reload }) => "avatar:moveFavorite": ({ avatarId, folder, reload }) =>
+5 -1
View File
@@ -305,7 +305,11 @@ async function restoreAvatarFavorite(
fav: { favoriteId: string; tags: string[] }, fav: { favoriteId: string; tags: string[] },
): Promise<void> { ): Promise<void> {
await vrc.addFavorite({ 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, 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"; else if (status === undefined && /network|fetch|ENOTFOUND|ECONN/i.test(message)) code = "network";
if (status === 429) { 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) { if (status !== undefined && status >= 500) {
return { code, message: "VRChat API is temporarily unavailable. Try again shortly." }; 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++) { for (let i = 1; mine.length < max && i <= max; i++) {
const name = `${prefix}${i}`; const name = `${prefix}${i}`;
if (taken.has(name)) continue; 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); out.push(...mine);
} }
@@ -354,10 +360,7 @@ export async function unfavoriteWorlds(worldIds: string[]): Promise<void> {
await reloadMyFavorites(); await reloadMyFavorites();
} }
export async function moveWorldsToFolder( export async function moveWorldsToFolder(worldIds: string[], folder: string): Promise<MoveResult> {
worldIds: string[],
folder: string,
): Promise<MoveResult> {
const vrc = requireActiveClient(); const vrc = requireActiveClient();
const me = await currentUser(); const me = await currentUser();
const records = await favoriteRecords(vrc); const records = await favoriteRecords(vrc);
@@ -458,10 +461,11 @@ async function favoriteTypeForExistingFolder(
query: { ownerId: userId, n: 100 }, query: { ownerId: userId, n: 100 },
throwOnError: true, throwOnError: true,
}); });
return ( const group = data.find(
data.find((g) => g.name === folder && isWorldGroupType(g.type))?.type ?? (g): g is (typeof data)[number] & { type: WorldFavoriteGroupType } =>
favoriteTypeForFolder(folder) g.name === folder && isWorldGroupType(g.type),
); );
return group?.type ?? favoriteTypeForFolder(folder);
} }
type AddFavoriteBody = NonNullable<Parameters<VRChat["addFavorite"]>[0]>["body"]; 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 }, query: { ownerId: me.id, n: 100 },
throwOnError: true, 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 pageSize = 100;
const entries: WorldFavoriteRecord[] = []; const entries: WorldFavoriteRecord[] = [];
for (const group of groups) { for (const group of groups) {
+3 -1
View File
@@ -23,7 +23,9 @@ export function CheckBox({
onChange(); onChange();
}} }}
className={`flex size-5 items-center justify-center rounded-md border transition-colors ${ 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} {indeterminate ? <Minus size={13} /> : checked ? <Check size={13} /> : null}
@@ -15,15 +15,9 @@ export function useAccountSettings(): {
const [nonce, setNonce] = useState(0); const [nonce, setNonce] = useState(0);
const [override, setOverride] = useState<AccountSettings | null>(null); const [override, setOverride] = useState<AccountSettings | null>(null);
const fetched = useAsync( const fetched = useAsync(() => api.settings.get(), [activeId, nonce], "Failed to load settings.");
() => api.settings.get(),
[activeId, nonce],
"Failed to load settings.",
);
const state: Async<AccountSettings> = override const state: Async<AccountSettings> = override ? { status: "ready", data: override } : fetched;
? { status: "ready", data: override }
: fetched;
const reload = useCallback(() => { const reload = useCallback(() => {
setOverride(null); setOverride(null);
@@ -26,7 +26,9 @@ export function AccountSwitcher() {
const status = self?.status ?? "offline"; const status = self?.status ?? "offline";
const activeName = active ? (demoMode ? demoName(active.id) : active.displayName) : "Account"; const activeName = active ? (demoMode ? demoName(active.id) : active.displayName) : "Account";
const activeIcon = demoMode ? undefined : active?.userIcon; 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() { function close() {
setOpen(false); setOpen(false);
@@ -57,9 +59,7 @@ export function AccountSwitcher() {
<span className="truncate text-left text-[13.5px] font-semibold leading-[1.25]"> <span className="truncate text-left text-[13.5px] font-semibold leading-[1.25]">
{activeName} {activeName}
</span> </span>
<span className="truncate text-left text-[11px] font-medium text-faint"> <span className="truncate text-left text-[11px] font-medium text-faint">{activeSub}</span>
{activeSub}
</span>
</span> </span>
<span <span
className={`acct__chevron shrink-0 text-faint transition-transform duration-[var(--dur)] ease-[var(--ease)] ${ 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> </Button>
{isOwner ? ( {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} /> <Pencil size={15} />
</Button> </Button>
<Button <Button
@@ -74,12 +78,14 @@ export function AvatarActions({ avatar }: { avatar: Avatar }) {
{error ? <p className="text-[12px] text-danger">{error}</p> : null} {error ? <p className="text-[12px] text-danger">{error}</p> : null}
{favoriteOpen ? ( {favoriteOpen ? (
<FavoriteModal avatar={avatar} currentFolder={folder} onClose={() => setFavoriteOpen(false)} /> <FavoriteModal
avatar={avatar}
currentFolder={folder}
onClose={() => setFavoriteOpen(false)}
/>
) : null} ) : null}
{editOpen ? ( {editOpen ? <EditAvatarModal avatar={avatar} onClose={() => setEditOpen(false)} /> : null}
<EditAvatarModal avatar={avatar} onClose={() => setEditOpen(false)} />
) : null}
<DeleteAvatarModal <DeleteAvatarModal
avatar={avatar} avatar={avatar}
@@ -41,9 +41,7 @@ export function AvatarCard({
{selectable ? ( {selectable ? (
<span <span
className={`absolute left-1.5 top-1.5 flex size-5 items-center justify-center rounded-md border transition-colors ${ className={`absolute left-1.5 top-1.5 flex size-5 items-center justify-center rounded-md border transition-colors ${
selected selected ? "border-accent bg-accent text-on-accent" : "border-border bg-surface-2/80"
? "border-accent bg-accent text-on-accent"
: "border-border bg-surface-2/80"
}`} }`}
> >
{selected ? <Check size={13} /> : null} {selected ? <Check size={13} /> : null}
@@ -17,7 +17,8 @@ export function AvatarView({ avatarId }: { avatarId: string }) {
const { avatar, failed } = useAvatar(avatarId); const { avatar, failed } = useAvatar(avatarId);
if (avatar) return <AvatarDetail avatar={avatar} />; 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 />; return <AvatarSkeleton />;
} }
@@ -55,7 +56,9 @@ function AvatarDetail({ avatar }: { avatar: Avatar }) {
{avatar.releaseStatus !== "public" ? ( {avatar.releaseStatus !== "public" ? (
<Tag color="var(--status-ask)">{avatar.releaseStatus}</Tag> <Tag color="var(--status-ask)">{avatar.releaseStatus}</Tag>
) : null} ) : 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?.pc ? <Tag>PC</Tag> : null}
{avatar.platforms?.android ? <Tag color="var(--status-join)">Quest</Tag> : null} {avatar.platforms?.android ? <Tag color="var(--status-join)">Quest</Tag> : null}
</div> </div>
@@ -126,7 +126,13 @@ function BulkBar({ ids, onDone }: { ids: string[]; onDone: () => void }) {
return ( return (
<SelectionBar label={t("avatar:bulk.selected", { count: ids.length })}> <SelectionBar label={t("avatar:bulk.selected", { count: ids.length })}>
{busy ? ( {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"> <span className="selection-bar__progress-label">
{t("avatar:bulk.unfavoritingProgress", { done: deleteProgress, total: ids.length })} {t("avatar:bulk.unfavoritingProgress", { done: deleteProgress, total: ids.length })}
</span> </span>
@@ -164,7 +170,10 @@ function BulkBar({ ids, onDone }: { ids: string[]; onDone: () => void }) {
{busy ? ( {busy ? (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className="text-[12px] text-muted"> <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> </span>
<div className="h-1 overflow-hidden rounded-full bg-surface-hover"> <div className="h-1 overflow-hidden rounded-full bg-surface-hover">
<div <div
@@ -205,8 +214,17 @@ function CurrentAvatarSection() {
const isOwner = avatar.authorId === self?.id; const isOwner = avatar.authorId === self?.id;
const menuItems: ContextMenuEntry[] = [ 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 }, { separator: true },
{ {
label: folder ? t("avatar:actions.manageFavorite") : t("avatar:actions.favorite"), label: folder ? t("avatar:actions.manageFavorite") : t("avatar:actions.favorite"),
@@ -214,15 +232,19 @@ function CurrentAvatarSection() {
onClick: () => setFavoriteOpen(true), onClick: () => setFavoriteOpen(true),
}, },
...(isOwner ...(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"), label: t("avatar:actions.delete"),
icon: <Trash2 size={14} />, icon: <Trash2 size={14} />,
danger: true, danger: true,
onClick: () => setDeleteOpen(true), onClick: () => setDeleteOpen(true),
}, },
] satisfies ContextMenuEntry[] ] satisfies ContextMenuEntry[])
: []), : []),
]; ];
@@ -241,7 +263,11 @@ function CurrentAvatarSection() {
/> />
</div> </div>
{favoriteOpen ? ( {favoriteOpen ? (
<FavoriteModal avatar={avatar} currentFolder={folder} onClose={() => setFavoriteOpen(false)} /> <FavoriteModal
avatar={avatar}
currentFolder={folder}
onClose={() => setFavoriteOpen(false)}
/>
) : null} ) : null}
{editOpen ? <EditAvatarModal avatar={avatar} onClose={() => setEditOpen(false)} /> : null} {editOpen ? <EditAvatarModal avatar={avatar} onClose={() => setEditOpen(false)} /> : null}
<DeleteAvatarModal <DeleteAvatarModal
@@ -266,7 +292,9 @@ function UploadedTab({ filter }: { filter: AvatarFilter }) {
const t = useT(); const t = useT();
const nav = useNav(); const nav = useNav();
const mine = useMyAvatars(); 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 [editAvatar, setEditAvatar] = useState<Avatar | null>(null);
const [deleteAvatar, setDeleteAvatar] = 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>; if (!shown.length) return <p className="text-[13px] text-faint">{t("avatar:empty")}</p>;
const menuItems = (avatar: Avatar): ContextMenuEntry[] => [ 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:context.open"),
{ label: t("avatar:actions.edit"), icon: <Pencil size={14} />, onClick: () => setEditAvatar(avatar) }, 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 }, { separator: true },
{ {
label: t("avatar:actions.delete"), label: t("avatar:actions.delete"),
@@ -363,7 +403,9 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
setSelected( setSelected(
new Set( new Set(
shown.flatMap((folder) => 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) => { setSelected((s) => {
const next = new Set(s); const next = new Set(s);
const all = ids.every((id) => next.has(id)); 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; return next;
}); });
@@ -398,9 +440,21 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
}; };
const menuItems = (avatar: Avatar, folder: string): ContextMenuEntry[] => [ 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:context.open"),
{ label: t("avatar:actions.wear"), icon: <Shirt size={14} />, onClick: () => void api.avatar.select(avatar.id) }, 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 }, { separator: true },
{ {
label: t("avatar:actions.manageFavorite"), label: t("avatar:actions.manageFavorite"),
@@ -480,7 +534,12 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
? undefined ? undefined
: (e) => { : (e) => {
e.preventDefault(); 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} ) : null}
{selecting && selected.size > 0 ? ( {selecting && selected.size > 0 ? <BulkBar ids={[...selected]} onDone={exitSelect} /> : null}
<BulkBar ids={[...selected]} onDone={exitSelect} />
) : null}
</div> </div>
); );
} }
@@ -65,7 +65,9 @@ export function FolderEditModal({
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
/> />
<label className="flex flex-col gap-1.5"> <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 <select
className={INPUT_CLASS} className={INPUT_CLASS}
value={visibility} value={visibility}
@@ -40,11 +40,18 @@ export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) {
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const { buildItems, modal } = useUserMenu(); const { buildItems, modal } = useUserMenu();
const openContextMenu = useCallback((e: React.MouseEvent, friend: UserProfile) => { const openContextMenu = useCallback(
(e: React.MouseEvent, friend: UserProfile) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, friend: demoMode ? anonymizeUserForDemo(friend) : friend }); setContextMenu({
}, [demoMode]); x: e.clientX,
y: e.clientY,
friend: demoMode ? anonymizeUserForDemo(friend) : friend,
});
},
[demoMode],
);
return ( return (
<aside className="friendsbar flex h-full flex-col overflow-hidden border-l border-border bg-surface"> <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> </header>
<div className="flex-1 overflow-y-auto p-2"> <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 ? ( {friends.length === 0 ? (
<p className="p-3 text-[13px] text-faint">{t("nav:friends.noFriendsOnline")}</p> <p className="p-3 text-[13px] text-faint">{t("nav:friends.noFriendsOnline")}</p>
@@ -65,7 +65,9 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
: presence.effective.label; : presence.effective.label;
const avatar = avatarOf(displayProfile); const avatar = avatarOf(displayProfile);
const banner = bannerOf(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 bioLinks = (displayProfile.bioLinks ?? []).filter(Boolean);
const showcasedBadges = (displayProfile.badges ?? []).filter((b) => b.showcased); const showcasedBadges = (displayProfile.badges ?? []).filter((b) => b.showcased);
@@ -87,7 +89,9 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
body={ body={
<div className="min-w-0 flex-1 pb-2"> <div className="min-w-0 flex-1 pb-2">
<div className="flex flex-wrap items-center gap-2.5"> <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.isSelf ? <Tag color="var(--accent)">{t("profile:badge.you")}</Tag> : null}
{profile.isFriend && !profile.isSelf ? ( {profile.isFriend && !profile.isSelf ? (
<Tag color="var(--status-join)">{t("profile:badge.friend")}</Tag> <Tag color="var(--status-join)">{t("profile:badge.friend")}</Tag>
@@ -212,9 +216,14 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
/> />
) : null} ) : null}
{displayProfile.state ? ( {displayProfile.state ? (
<Fact label={t("profile:facts.state")} value={stateLabel(displayProfile.state, t)} /> <Fact
label={t("profile:facts.state")}
value={stateLabel(displayProfile.state, t)}
/>
) : null} ) : null}
{demoMode ? null : <Fact label={t("profile:facts.userId")} value={profile.id} mono />} {demoMode ? null : (
<Fact label={t("profile:facts.userId")} value={profile.id} mono />
)}
</dl> </dl>
</Section> </Section>
@@ -38,7 +38,8 @@ export function useFavoriteWorlds(userId: string): {
const fallbackFolders = useMemo<FavoriteWorldFolder[]>(() => [], []); const fallbackFolders = useMemo<FavoriteWorldFolder[]>(() => [], []);
const streamedFolders = streamed?.userId === userId ? streamed.folders : fallbackFolders; 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 ids = useMemo(() => folders.flatMap((f) => f.worldIds), [folders]);
const worlds = useWorlds(useShallow((s) => ids.map((id) => s.worlds[id]).filter(Boolean))); 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)} onChange={(e) => setPath(e.target.value)}
/> />
</div> </div>
<Button <Button variant="ghost" onClick={() => void run(pickGamePath(), t("settings:game.saved"))}>
variant="ghost"
onClick={() => void run(pickGamePath(), t("settings:game.saved"))}
>
<FolderOpen size={14} /> <FolderOpen size={14} />
{t("settings:game.browse")} {t("settings:game.browse")}
</Button> </Button>
@@ -380,9 +377,7 @@ function GameSection() {
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-2.5"> <div className="mt-3 flex flex-wrap items-center gap-2.5">
<Button <Button
onClick={() => onClick={() => void run(setGamePath(path.trim() || null), t("settings:game.saved"))}
void run(setGamePath(path.trim() || null), t("settings:game.saved"))
}
loading={busy} loading={busy}
disabled={!dirty} disabled={!dirty}
> >
@@ -102,7 +102,7 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
setSelected((s) => { setSelected((s) => {
const next = new Set(s); const next = new Set(s);
const all = ids.every((id) => next.has(id)); 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; return next;
}); });
@@ -118,7 +118,11 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
}; };
const menuItems = (world: World, folder: string): ContextMenuEntry[] => [ 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"), label: t("world:context.select"),
icon: <CheckSquare size={14} />, icon: <CheckSquare size={14} />,
@@ -144,7 +148,10 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
{selecting ? ( {selecting ? (
<> <>
<Button variant="ghost" onClick={() => selectWhere((w) => w.releaseStatus === "private")}> <Button
variant="ghost"
onClick={() => selectWhere((w) => w.releaseStatus === "private")}
>
{t("world:bulk.selectPrivate")} {t("world:bulk.selectPrivate")}
</Button> </Button>
{hasDeleted ? ( {hasDeleted ? (
@@ -201,7 +208,12 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
? undefined ? undefined
: (e) => { : (e) => {
e.preventDefault(); 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 ( return (
<Card <Card
onClick={ onClick={
selectable ? onToggleSelect : world.deleted ? undefined : (onOpen ?? (() => openWorld(world.id))) selectable
? onToggleSelect
: world.deleted
? undefined
: (onOpen ?? (() => openWorld(world.id)))
} }
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
className={`${selected ? "outline outline-[3px] -outline-offset-[3px] outline-accent" : ""} ${ className={`${selected ? "outline outline-[3px] -outline-offset-[3px] outline-accent" : ""} ${
+14 -3
View File
@@ -83,9 +83,17 @@ function WorldCard({ world }: { world: World }) {
</div> </div>
} }
> >
<CreateInstanceModal worldId={world.id} open={createOpen} onClose={() => setCreateOpen(false)} /> <CreateInstanceModal
worldId={world.id}
open={createOpen}
onClose={() => setCreateOpen(false)}
/>
{favoriteOpen ? ( {favoriteOpen ? (
<WorldFavoriteModal world={world} currentFolder={folder} onClose={() => setFavoriteOpen(false)} /> <WorldFavoriteModal
world={world}
currentFolder={folder}
onClose={() => setFavoriteOpen(false)}
/>
) : null} ) : null}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -153,7 +161,10 @@ function WorldCard({ world }: { world: World }) {
<Fact label={t("world:detail.facts.version")} value={`${world.version}`} /> <Fact label={t("world:detail.facts.version")} value={`${world.version}`} />
) : null} ) : null}
{world.publishedAt ? ( {world.publishedAt ? (
<Fact label={t("world:detail.facts.published")} value={formatDate(world.publishedAt)} /> <Fact
label={t("world:detail.facts.published")}
value={formatDate(world.publishedAt)}
/>
) : null} ) : null}
{world.labsPublishedAt ? ( {world.labsPublishedAt ? (
<Fact <Fact
+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 type { AppConfig, AppPreferences, PreferredRegion } from "../../../shared/types/appConfig";
import { api } from "./api"; import { api } from "./api";
+5 -2
View File
@@ -45,11 +45,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [accent, setAccentState] = useState<string | null>(null); const [accent, setAccentState] = useState<string | null>(null);
const lastApplied = useRef<string>(""); const lastApplied = useRef<string>("");
const setAccent = useCallback((hex: string | null) => { const setAccent = useCallback(
(hex: string | null) => {
applyAccent(hex); applyAccent(hex);
setAccentState(hex); setAccentState(hex);
void setPreferences({ accent: hex }); void setPreferences({ accent: hex });
}, [setPreferences]); },
[setPreferences],
);
const setTheme = useCallback( const setTheme = useCallback(
(id: string) => { (id: string) => {
+7 -2
View File
@@ -24,7 +24,11 @@ export function errorMessage(err: unknown, fallback: string): string {
export function isRetryableApiError(err: unknown): boolean { export function isRetryableApiError(err: unknown): boolean {
if (!(err instanceof ApiException)) return false; 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; type DataOf<R> = R extends { ok: true; data: infer D } ? D : never;
@@ -163,7 +167,8 @@ export const api = {
game: { game: {
status: () => call("game:status"), status: () => call("game:status"),
launch: () => call("game:launch"), 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), openProtocol: (url: string) => call("game:openProtocol", url),
}, },
gallery: { gallery: {
+1 -7
View File
@@ -1,9 +1,3 @@
export { I18nProvider, useI18n, useT } from "./I18nContext"; export { I18nProvider, useI18n, useT } from "./I18nContext";
export { export { DEFAULT_LOCALE, NAMESPACES, availableLocales, getLocale, i18n } from "./registry";
DEFAULT_LOCALE,
NAMESPACES,
availableLocales,
getLocale,
i18n,
} from "./registry";
export type { Locale, LocaleCode, LocaleMeta, Messages } from "./types"; export type { Locale, LocaleCode, LocaleMeta, Messages } from "./types";
+1 -2
View File
@@ -69,8 +69,7 @@ function toFolder(
}; };
} }
export const useWorldFavoriteLimits = (): FavoriteLimits => export const useWorldFavoriteLimits = (): FavoriteLimits => useWorldFavorites((s) => s.limits);
useWorldFavorites((s) => s.limits);
export const useWorldFavoritesLoaded = (): boolean => useWorldFavorites((s) => s.loaded); 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:unfavoriteMany": (avatarIds: string[]) => IpcResult<void>;
"avatar:moveFavoriteMany": (p: { avatarIds: string[]; folder: string }) => IpcResult<MoveResult>; "avatar:moveFavoriteMany": (p: { avatarIds: string[]; folder: string }) => IpcResult<MoveResult>;
"avatar:clearFavoriteFolder": (folder: string) => IpcResult<void>; "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:byUser": (userId: string) => IpcResult<Group[]>;
"group:represented": (userId: string) => IpcResult<Group | null>; "group:represented": (userId: string) => IpcResult<Group | null>;