feat: improve favorite bulk moves

This commit is contained in:
2026-06-30 20:44:50 +07:00
parent 5bbef18fdf
commit 7151a3ac90
26 changed files with 702 additions and 218 deletions
+6 -4
View File
@@ -58,8 +58,9 @@ const handlers = {
"world:loadFavorites": () => guard(() => worlds.loadMyFavoriteWorlds()),
"world:favorite": ({ worldId, folder }) => guard(() => worlds.favoriteWorld(worldId, folder)),
"world:unfavorite": (worldId) => guard(() => worlds.unfavoriteWorld(worldId)),
"world:moveFavorite": ({ worldId, folder }) =>
guard(() => worlds.moveWorldToFolder(worldId, folder)),
"world:reloadFavorites": () => guard(() => worlds.reloadMyFavorites()),
"world:moveFavorite": ({ worldId, folder, reload }) =>
guard(() => worlds.moveWorldToFolder(worldId, folder, reload)),
"world:unfavoriteMany": (worldIds) => guard(() => worlds.unfavoriteWorlds(worldIds)),
"world:moveFavoriteMany": ({ worldIds, folder }) =>
guard(() => worlds.moveWorldsToFolder(worldIds, folder)),
@@ -82,8 +83,9 @@ const handlers = {
"avatar:delete": (avatarId) => guard(() => avatars.deleteAvatar(avatarId)),
"avatar:favorite": ({ avatarId, folder }) => guard(() => avatars.favoriteAvatar(avatarId, folder)),
"avatar:unfavorite": (avatarId) => guard(() => avatars.unfavoriteAvatar(avatarId)),
"avatar:moveFavorite": ({ avatarId, folder }) =>
guard(() => avatars.moveAvatarToFolder(avatarId, folder)),
"avatar:reloadFavorites": () => guard(() => avatars.reloadFavorites()),
"avatar:moveFavorite": ({ avatarId, folder, reload }) =>
guard(() => avatars.moveAvatarToFolder(avatarId, folder, reload)),
"avatar:unfavoriteMany": (avatarIds) => guard(() => avatars.unfavoriteAvatars(avatarIds)),
"avatar:moveFavoriteMany": ({ avatarIds, folder }) =>
guard(() => avatars.moveAvatarsToFolder(avatarIds, folder)),
+15
View File
@@ -14,6 +14,7 @@ export type FavoriteGroupInput = {
displayName: string;
visibility: FavoriteVisibility;
worlds: World[];
vrcPlus: boolean;
};
type Listener = (snapshot: WorldFavoritesSnapshot) => void;
@@ -34,12 +35,26 @@ class WorldFavoritesStore {
displayName: g.displayName,
visibility: g.visibility,
worldIds: g.worlds.map((w) => w.id),
vrcPlus: g.vrcPlus,
}));
if (limits) this.limits = limits;
for (const g of groups) for (const w of g.worlds) worldStore.addWorld(w);
this.emit();
}
moveWorld(worldId: string, folder: string): void {
let changed = false;
this.groups = this.groups.map((g) => {
const without = g.worldIds.filter((id) => id !== worldId);
const worldIds = g.name === folder ? [...without, worldId] : without;
if (worldIds.length !== g.worldIds.length || worldIds.some((id, i) => id !== g.worldIds[i])) {
changed = true;
}
return { ...g, worldIds };
});
if (changed) this.emit();
}
snapshot(): WorldFavoritesSnapshot {
return { groups: this.groups, limits: this.limits };
}
+76 -20
View File
@@ -9,7 +9,7 @@ import type {
MoveResult,
} from "../../shared/types/avatar";
import { toAvatar } from "./mappers";
import { httpStatusOf } from "./errors";
import { httpStatusOf, isTransientError } from "./errors";
import { cachedRead } from "./cachedRead";
import { requireActiveClient } from "./client";
import { userCache, currentUser } from "./userService";
@@ -70,7 +70,7 @@ async function fetchFavorites(vrc: VRChat): Promise<{
]);
const avatarGroups = groups.filter((g) => g.type === "avatar");
const folders: FavoriteFolder[] = [];
const existing: FavoriteFolder[] = [];
for (const group of avatarGroups) {
let raw: Awaited<ReturnType<typeof getFavoritedAvatarsRaw>> = [];
try {
@@ -78,14 +78,37 @@ async function fetchFavorites(vrc: VRChat): Promise<{
} catch (err) {
if (httpStatusOf(err) !== 401 && httpStatusOf(err) !== 403) throw err;
}
folders.push({
existing.push({
name: group.name,
displayName: group.displayName || prettyFolderName(group.name),
visibility: normalizeVisibility(group.visibility),
avatars: raw.map(toAvatar),
});
}
return { folders, limits };
return { folders: fillAvatarSlots(existing, limits.maxGroups), limits };
}
function fillAvatarSlots(existing: FavoriteFolder[], max: number): FavoriteFolder[] {
const out = orderSlots(existing, "avatars");
const taken = new Set(out.map((f) => f.name));
for (let i = 1; out.length < max && i <= max; i++) {
const name = `avatars${i}`;
if (taken.has(name)) continue;
out.push({ name, displayName: prettyFolderName(name), visibility: "private", avatars: [] });
}
return out;
}
function orderSlots<T extends { name: string }>(groups: T[], prefix: string): T[] {
const slotNum = (name: string) => {
const m = new RegExp(`^${prefix}(\\d+)$`).exec(name);
return m ? Number(m[1]) : null;
};
const custom = groups.filter((g) => slotNum(g.name) === null);
const numbered = groups
.filter((g) => slotNum(g.name) !== null)
.sort((a, b) => slotNum(a.name)! - slotNum(b.name)!);
return [...custom, ...numbered];
}
async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
@@ -164,17 +187,28 @@ export async function unfavoriteAvatar(avatarId: string): Promise<void> {
await reloadFavorites();
}
export async function moveAvatarToFolder(avatarId: string, folder: string): Promise<MoveResult> {
export async function moveAvatarToFolder(
avatarId: string,
folder: string,
reload = true,
): Promise<MoveResult> {
const vrc = requireActiveClient();
const fav = await findFavoriteRecord(vrc, avatarId);
if (fav?.tags?.includes(folder)) return { moved: 0, skipped: [] };
if (!(await canRefavorite(vrc, avatarId))) return { moved: 0, skipped: [avatarId] };
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
await vrc.addFavorite({
body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
throwOnError: true,
});
await reloadFavorites();
try {
await vrc.addFavorite({
body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
throwOnError: true,
});
} catch (err) {
if (fav) await restoreAvatarFavorite(vrc, fav);
if (reload) await reloadFavorites();
if (isTransientError(err)) throw err;
return { moved: 0, skipped: [avatarId] };
}
if (reload) await reloadFavorites();
return { moved: 1, skipped: [] };
}
@@ -183,7 +217,7 @@ export async function unfavoriteAvatars(avatarIds: string[]): Promise<void> {
const records = await favoriteRecords(vrc);
for (const id of avatarIds) {
const fav = records.get(id);
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav }, throwOnError: true });
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
}
await reloadFavorites();
}
@@ -202,11 +236,22 @@ export async function moveAvatarsToFolder(
continue;
}
const fav = records.get(id);
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav }, throwOnError: true });
await vrc.addFavorite({
body: { type: "avatar", favoriteId: id, tags: [folder] },
throwOnError: true,
});
if (fav?.tags?.includes(folder)) continue;
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
try {
await vrc.addFavorite({
body: { type: "avatar", favoriteId: id, tags: [folder] },
throwOnError: true,
});
} catch (err) {
if (fav) await restoreAvatarFavorite(vrc, fav);
if (isTransientError(err)) {
await reloadFavorites();
throw err;
}
skipped.push(id);
continue;
}
moved++;
}
await reloadFavorites();
@@ -217,7 +262,8 @@ async function canRefavorite(vrc: VRChat, avatarId: string): Promise<boolean> {
try {
await getAvatarRaw(vrc, avatarId);
return true;
} catch {
} catch (err) {
if (isTransientError(err)) throw err;
return false;
}
}
@@ -250,8 +296,18 @@ async function findFavoriteRecord(vrc: VRChat, avatarId: string) {
return (await favoriteRecordEntries(vrc)).find((f) => f.favoriteId === avatarId);
}
async function favoriteRecords(vrc: VRChat): Promise<Map<string, string>> {
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f.id]));
async function favoriteRecords(vrc: VRChat) {
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f]));
}
async function restoreAvatarFavorite(
vrc: VRChat,
fav: { favoriteId: string; tags: string[] },
): Promise<void> {
await vrc.addFavorite({
body: { type: "avatar", favoriteId: fav.favoriteId, tags: fav.tags.length ? fav.tags : ["avatars1"] },
throwOnError: true,
});
}
async function favoriteRecordEntries(vrc: VRChat) {
@@ -267,7 +323,7 @@ async function favoriteRecordEntries(vrc: VRChat) {
}
}
async function reloadFavorites(): Promise<void> {
export async function reloadFavorites(): Promise<void> {
userCache.invalidate(cacheKeys.avatarFavorites());
await loadFavoritedAvatars();
}
+13
View File
@@ -28,6 +28,12 @@ export function httpStatusOf(err: unknown): number | undefined {
return statusOf((err ?? {}) as HttpLike);
}
export function isTransientError(err: unknown): boolean {
const status = httpStatusOf(err);
if (status === undefined) return true;
return status === 429 || status >= 500;
}
function retryAfterOf(e: HttpLike): number | undefined {
const raw = e.response?.headers?.["retry-after"] ?? e.headers?.["retry-after"];
const n = raw != null ? Number(raw) : NaN;
@@ -47,6 +53,13 @@ export function toApiError(err: unknown): ApiError {
else if (status === 429) code = "rate_limited";
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) };
}
if (status !== undefined && status >= 500) {
return { code, message: "VRChat API is temporarily unavailable. Try again shortly." };
}
return { code, message, retryAfter: retryAfterOf(e) };
}
+185 -49
View File
@@ -8,7 +8,7 @@ import type {
MoveResult,
World,
} from "../../shared/types/world";
import { httpStatusOf } from "./errors";
import { httpStatusOf, isTransientError } from "./errors";
import {
getCategoryWorlds,
getFavoriteGroupWorlds,
@@ -71,6 +71,7 @@ async function loadFavoriteWorlds(vrc: VRChat, userId: string): Promise<CachedFa
const seen = new Set<string>();
const members: { id: string; group: string }[] = [];
const names = new Map<string, string>();
const order = groups.map((g) => g.name);
for (const group of groups) {
if (group.displayName) names.set(group.name, group.displayName);
let raw;
@@ -91,12 +92,12 @@ async function loadFavoriteWorlds(vrc: VRChat, userId: string): Promise<CachedFa
}
broadcast("world:favoriteFolders", {
userId,
folders: groupIntoFolders(members, names),
folders: groupIntoFolders(order, members, names),
done: false,
});
}
const folders = groupIntoFolders(members, names);
const folders = groupIntoFolders(order, members, names);
broadcast("world:favoriteFolders", { userId, folders, done: true });
return { worlds, folders };
}
@@ -111,27 +112,26 @@ function isPrivateFavorites(err: unknown): boolean {
}
function groupIntoFolders(
order: string[],
members: { id: string; group: string }[],
names: Map<string, string>,
): FavoriteWorldFolder[] {
const order: string[] = [];
const byGroup = new Map<string, string[]>();
for (const { id, group } of members) {
const ids = byGroup.get(group);
if (ids) ids.push(id);
else {
byGroup.set(group, [id]);
order.push(group);
}
else byGroup.set(group, [id]);
}
return order.map((name) => ({
name,
displayName: names.get(name) ?? prettyFolderName(name),
worldIds: byGroup.get(name)!,
worldIds: byGroup.get(name) ?? [],
}));
}
function prettyFolderName(key: string): string {
const vp = /^vrcPlusWorlds(\d+)$/.exec(key);
if (vp) return `VRC+ Group ${vp[1]}`;
const m = /^worlds(\d+)$/.exec(key);
if (m) return `Group ${m[1]}`;
return key.charAt(0).toUpperCase() + key.slice(1);
@@ -175,6 +175,13 @@ async function loadCategory(
const DEFAULT_FOLDER = "worlds1";
type WorldFavoriteRecord = {
id: string;
favoriteId: string;
tags: string[];
type: WorldFavoriteGroupType;
};
export async function loadMyFavoriteWorlds(): Promise<void> {
const me = await currentUser();
const { groups, limits } = await cachedRead(
@@ -208,13 +215,13 @@ async function fetchMyFavorites(
vrc: VRChat,
userId: string,
): Promise<{ groups: FavoriteGroupInput[]; limits: FavoriteLimits }> {
const [{ data: rawGroups }, limits] = await Promise.all([
const [{ data: rawGroups }, { limits, caps }] = await Promise.all([
vrc.getFavoriteGroups({ query: { ownerId: userId, n: 100 }, throwOnError: true }),
fetchFavoriteLimits(vrc),
]);
const worldGroups = rawGroups.filter((g) => isWorldGroupType(g.type));
const groups: FavoriteGroupInput[] = [];
const existing: FavoriteGroupInput[] = [];
for (const group of worldGroups) {
let worlds: World[] = [];
try {
@@ -228,21 +235,64 @@ async function fetchMyFavorites(
} catch (err) {
if (!isPrivateFavorites(err)) throw err;
}
groups.push({
existing.push({
name: group.name,
displayName: group.displayName || prettyFolderName(group.name),
visibility: normalizeVisibility(group.visibility),
worlds,
vrcPlus: (group.type as WorldFavoriteGroupType) === "vrcPlusWorld",
});
}
return { groups, limits };
return { groups: fillSlots(existing, caps), limits };
}
async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
function fillSlots(existing: FavoriteGroupInput[], caps: WorldFavoriteCaps): FavoriteGroupInput[] {
const out: FavoriteGroupInput[] = [];
for (const [prefix, vrcPlus, max] of [
["worlds", false, caps.world],
["vrcPlusWorlds", true, caps.vrcPlusWorld],
] as const) {
const mine = orderSlots(
existing.filter((g) => g.vrcPlus === vrcPlus),
prefix,
);
const taken = new Set(mine.map((g) => g.name));
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 });
}
out.push(...mine);
}
return out;
}
function orderSlots<T extends { name: string }>(groups: T[], prefix: string): T[] {
const slotNum = (name: string) => {
const m = new RegExp(`^${prefix}(\\d+)$`).exec(name);
return m ? Number(m[1]) : null;
};
const custom = groups.filter((g) => slotNum(g.name) === null);
const numbered = groups
.filter((g) => slotNum(g.name) !== null)
.sort((a, b) => slotNum(a.name)! - slotNum(b.name)!);
return [...custom, ...numbered];
}
type WorldFavoriteCaps = { world: number; vrcPlusWorld: number };
async function fetchFavoriteLimits(
vrc: VRChat,
): Promise<{ limits: FavoriteLimits; caps: WorldFavoriteCaps }> {
const { data } = await vrc.getFavoriteLimits({ throwOnError: true });
const world = data.maxFavoriteGroups?.world ?? data.defaultMaxFavoriteGroups;
const vrcPlusWorld = data.maxFavoriteGroups?.vrcPlusWorld ?? 0;
return {
maxGroups: data.maxFavoriteGroups?.world ?? data.defaultMaxFavoriteGroups,
maxPerGroup: data.maxFavoritesPerGroup?.world ?? data.defaultMaxFavoritesPerGroup,
limits: {
maxGroups: world + vrcPlusWorld,
maxPerGroup: data.maxFavoritesPerGroup?.world ?? data.defaultMaxFavoritesPerGroup,
},
caps: { world, vrcPlusWorld },
};
}
@@ -253,7 +303,7 @@ function normalizeVisibility(v: string): FavoriteVisibility {
export async function favoriteWorld(worldId: string, folder = DEFAULT_FOLDER): Promise<void> {
const vrc = requireActiveClient();
await vrc.addFavorite({
body: { type: "world", favoriteId: worldId, tags: [folder] },
body: favoriteBody(favoriteTypeForFolder(folder), worldId, [folder]),
throwOnError: true,
});
await reloadMyFavorites();
@@ -266,17 +316,31 @@ export async function unfavoriteWorld(worldId: string): Promise<void> {
await reloadMyFavorites();
}
export async function moveWorldToFolder(worldId: string, folder: string): Promise<MoveResult> {
export async function moveWorldToFolder(
worldId: string,
folder: string,
reload = true,
): Promise<MoveResult> {
const vrc = requireActiveClient();
const me = await currentUser();
const fav = await findFavoriteRecord(vrc, worldId);
if (fav?.tags?.includes(folder)) return { moved: 0, skipped: [] };
if (isOnlyInFolder(fav, folder)) return { moved: 0, skipped: [] };
if (!(await canRefavorite(vrc, worldId))) return { moved: 0, skipped: [worldId] };
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
await vrc.addFavorite({
body: { type: "world", favoriteId: worldId, tags: [folder] },
throwOnError: true,
});
await reloadMyFavorites();
try {
await vrc.addFavorite({
body: favoriteBody(type, worldId, [folder]),
throwOnError: true,
});
} catch (err) {
if (fav) await restoreWorldFavorite(vrc, fav);
if (reload) await reloadMyFavorites();
if (isTransientError(err)) throw err;
return { moved: 0, skipped: [worldId] };
}
if (reload) await reloadMyFavorites();
worldFavoritesStore.moveWorld(worldId, folder);
return { moved: 1, skipped: [] };
}
@@ -284,8 +348,8 @@ export async function unfavoriteWorlds(worldIds: string[]): Promise<void> {
const vrc = requireActiveClient();
const records = await favoriteRecords(vrc);
for (const id of worldIds) {
const recordId = records.get(id);
if (recordId) await vrc.removeFavorite({ path: { favoriteId: recordId }, throwOnError: true });
const fav = records.get(id);
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
}
await reloadMyFavorites();
}
@@ -295,7 +359,9 @@ export async function moveWorldsToFolder(
folder: string,
): Promise<MoveResult> {
const vrc = requireActiveClient();
const me = await currentUser();
const records = await favoriteRecords(vrc);
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
const skipped: string[] = [];
let moved = 0;
for (const id of worldIds) {
@@ -303,23 +369,48 @@ export async function moveWorldsToFolder(
skipped.push(id);
continue;
}
const recordId = records.get(id);
if (recordId) await vrc.removeFavorite({ path: { favoriteId: recordId }, throwOnError: true });
await vrc.addFavorite({
body: { type: "world", favoriteId: id, tags: [folder] },
throwOnError: true,
});
const fav = records.get(id);
if (isOnlyInFolder(fav, folder)) continue;
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
try {
await vrc.addFavorite({
body: favoriteBody(type, id, [folder]),
throwOnError: true,
});
} catch (err) {
if (fav) await restoreWorldFavorite(vrc, fav);
if (isTransientError(err)) {
await reloadMyFavorites();
throw err;
}
skipped.push(id);
continue;
}
moved++;
}
await reloadMyFavorites();
for (const id of worldIds) if (!skipped.includes(id)) worldFavoritesStore.moveWorld(id, folder);
return { moved, skipped };
}
async function restoreWorldFavorite(vrc: VRChat, fav: WorldFavoriteRecord): Promise<void> {
const tags = fav.tags.length ? fav.tags : [DEFAULT_FOLDER];
await vrc.addFavorite({
body: favoriteBody(fav.type, fav.favoriteId, tags),
throwOnError: true,
});
}
function isOnlyInFolder(fav: WorldFavoriteRecord | undefined, folder: string): boolean {
return fav?.tags.length === 1 && fav.tags[0] === folder;
}
export async function clearFavoriteWorldFolder(folder: string): Promise<void> {
const vrc = requireActiveClient();
const me = await currentUser();
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
await vrc.clearFavoriteGroup({
path: { favoriteGroupType: "world", favoriteGroupName: folder, userId: me.id },
path: { favoriteGroupType: type, favoriteGroupName: folder, userId: me.id },
throwOnError: true,
});
await reloadMyFavorites();
@@ -331,8 +422,9 @@ export async function updateFavoriteWorldFolder(
): Promise<void> {
const vrc = requireActiveClient();
const me = await currentUser();
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
await vrc.updateFavoriteGroup({
path: { favoriteGroupType: "world", favoriteGroupName: folder, userId: me.id },
path: { favoriteGroupType: type, favoriteGroupName: folder, userId: me.id },
body: {
displayName: edit.displayName,
visibility: edit.visibility as never,
@@ -344,35 +436,79 @@ export async function updateFavoriteWorldFolder(
async function canRefavorite(vrc: VRChat, worldId: string): Promise<boolean> {
try {
await vrc.getWorld({ path: { worldId }, throwOnError: true });
return true;
} catch {
const { data } = await vrc.getWorld({ path: { worldId }, throwOnError: true });
const me = await currentUser();
return data.releaseStatus !== "private" || data.authorId === me.id;
} catch (err) {
if (isTransientError(err)) throw err;
return false;
}
}
function favoriteTypeForFolder(folder: string): WorldFavoriteGroupType {
return folder.startsWith("vrcPlusWorlds") ? "vrcPlusWorld" : "world";
}
async function favoriteTypeForExistingFolder(
vrc: VRChat,
userId: string,
folder: string,
): Promise<WorldFavoriteGroupType> {
const { data } = await vrc.getFavoriteGroups({
query: { ownerId: userId, n: 100 },
throwOnError: true,
});
return (
data.find((g) => g.name === folder && isWorldGroupType(g.type))?.type ??
favoriteTypeForFolder(folder)
);
}
type AddFavoriteBody = NonNullable<Parameters<VRChat["addFavorite"]>[0]>["body"];
function favoriteBody(
type: WorldFavoriteGroupType,
favoriteId: string,
tags: string[],
): AddFavoriteBody {
return { type, favoriteId, tags } as AddFavoriteBody;
}
async function findFavoriteRecord(vrc: VRChat, worldId: string) {
return (await favoriteRecordEntries(vrc)).find((f) => f.favoriteId === worldId);
}
async function favoriteRecords(vrc: VRChat): Promise<Map<string, string>> {
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f.id]));
async function favoriteRecords(vrc: VRChat): Promise<Map<string, WorldFavoriteRecord>> {
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f]));
}
async function favoriteRecordEntries(vrc: VRChat) {
async function favoriteRecordEntries(vrc: VRChat): Promise<WorldFavoriteRecord[]> {
const me = await currentUser();
const { data: rawGroups } = await vrc.getFavoriteGroups({
query: { ownerId: me.id, n: 100 },
throwOnError: true,
});
const groups = rawGroups.filter((g) => isWorldGroupType(g.type));
const pageSize = 100;
const entries = [];
for (let offset = 0; ; offset += pageSize) {
const { data } = await vrc.getFavorites({
query: { type: "world", n: pageSize, offset },
throwOnError: true,
});
entries.push(...data);
if (data.length < pageSize) return entries;
const entries: WorldFavoriteRecord[] = [];
for (const group of groups) {
let groupCount = 0;
for (let offset = 0; ; offset += pageSize) {
const res = await vrc.client.get({
url: `/favorites/groups/${group.type}/${encodeURIComponent(group.name)}`,
query: { ownerId: me.id, n: pageSize, offset },
throwOnError: true,
});
const data = res.data as { favorites?: WorldFavoriteRecord[]; totalCount?: number };
const page = (data.favorites ?? []).map((f) => ({ ...f, type: group.type }));
entries.push(...page);
groupCount += page.length;
if (page.length < pageSize || groupCount >= (data.totalCount ?? groupCount)) break;
}
}
return entries;
}
async function reloadMyFavorites(): Promise<void> {
export async function reloadMyFavorites(): Promise<void> {
userCache.invalidate(cacheKeys.myFavoriteWorlds());
const me = await currentUser();
userCache.invalidate(cacheKeys.favoriteWorlds(me.id));
@@ -0,0 +1,32 @@
import { Check, Minus } from "lucide-react";
export function CheckBox({
checked,
indeterminate,
onChange,
"aria-label": ariaLabel,
}: {
checked: boolean;
indeterminate?: boolean;
onChange: () => void;
"aria-label"?: string;
}) {
const on = checked || indeterminate;
return (
<button
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
onClick={(e) => {
e.stopPropagation();
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"
}`}
>
{indeterminate ? <Minus size={13} /> : checked ? <Check size={13} /> : null}
</button>
);
}
+16 -11
View File
@@ -16,6 +16,7 @@ type ModalProps = {
onConfirm?: () => void;
confirmLoading?: boolean;
confirmDisabled?: boolean;
dismissible?: boolean;
};
export function Modal({
@@ -30,16 +31,18 @@ export function Modal({
onConfirm,
confirmLoading,
confirmDisabled,
dismissible = true,
}: ModalProps) {
const t = useT();
const close = dismissible ? onClose : () => {};
useEffect(() => {
if (!open) return;
if (!open || !dismissible) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
}, [open, dismissible, onClose]);
if (!open) return null;
@@ -53,7 +56,7 @@ export function Modal({
<button
aria-hidden
tabIndex={-1}
onClick={onClose}
onClick={close}
className="absolute inset-0 animate-[fade-in_var(--dur)_var(--ease-out)_both] bg-[color-mix(in_srgb,var(--surface)_30%,#000_55%)] backdrop-blur-[2px]"
/>
<div className="animate-[pop-in_var(--dur)_var(--ease-out)_both] relative w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-[0_24px_60px_-20px_rgba(0,0,0,0.5)]">
@@ -66,20 +69,22 @@ export function Modal({
{icon}
{title}
</h2>
<button
onClick={onClose}
aria-label="Close"
className="grid size-7 shrink-0 place-items-center rounded-md text-faint transition-colors hover:bg-surface-hover hover:text-text"
>
<X size={16} />
</button>
{dismissible ? (
<button
onClick={onClose}
aria-label="Close"
className="grid size-7 shrink-0 place-items-center rounded-md text-faint transition-colors hover:bg-surface-hover hover:text-text"
>
<X size={16} />
</button>
) : null}
</div>
<div className="text-[13px] text-muted">{children}</div>
{onConfirm ? (
<div className="mt-5 flex justify-end gap-2.5">
<Button variant="ghost" onClick={onClose} disabled={confirmLoading}>
<Button variant="ghost" onClick={onClose} disabled={confirmLoading || !dismissible}>
{cancelLabel ?? t("common:cancel")}
</Button>
<Button
+1
View File
@@ -12,6 +12,7 @@ export { Stat } from "./Stat";
export { PresenceAvatar } from "./PresenceAvatar";
export { PresenceLabel } from "./PresenceLabel";
export { CollapsibleCard } from "./CollapsibleCard";
export { CheckBox } from "./CheckBox";
export { Modal } from "./Modal";
export { Toggle } from "./Toggle";
export { Section, Fact } from "./Section";
@@ -4,6 +4,7 @@ import { CheckSquare, Eye, FolderInput, Pencil, Shirt, Star, Trash2, X } from "l
import {
Button,
CardGrid,
CheckBox,
CollapsibleCard,
ContextMenu,
Field,
@@ -374,6 +375,22 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
setSelecting(true);
};
const folderState = (ids: string[]) => {
const picked = ids.filter((id) => selected.has(id)).length;
return {
checked: picked > 0 && picked === ids.length,
indeterminate: picked > 0 && picked < ids.length,
};
};
const toggleFolder = (ids: string[]) =>
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));
return next;
});
const removeFavorite = async () => {
if (!removeAvatar) return;
await api.avatar.unfavorite(removeAvatar.id);
@@ -430,13 +447,22 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
title={folder.displayName}
count={`${folder.count} / ${maxPerGroup}`}
action={
<IconButton
title={t("avatar:folder.edit")}
onClick={() => setEditFolder(folder)}
aria-label={t("avatar:folder.edit")}
>
<Pencil size={14} />
</IconButton>
<div className="flex items-center gap-2">
{selecting && folder.avatars.length ? (
<CheckBox
{...folderState(folder.avatars.map((a) => a.id))}
onChange={() => toggleFolder(folder.avatars.map((a) => a.id))}
aria-label={t("avatar:bulk.selectFolder", { name: folder.displayName })}
/>
) : null}
<IconButton
title={t("avatar:folder.edit")}
onClick={() => setEditFolder(folder)}
aria-label={t("avatar:folder.edit")}
>
<Pencil size={14} />
</IconButton>
</div>
}
>
{folder.avatars.length ? (
@@ -1,9 +1,9 @@
import { useState } from "react";
import { FolderInput, Star } from "lucide-react";
import { Modal } from "../../components/ui";
import { api, errorMessage } from "../../lib/api";
import { api, errorMessage, isRetryableApiError } from "../../lib/api";
import { useT } from "../../lib/i18n";
import { useFolderSlots } from "../../store/avatars";
import { useAvatars, useFolderSlots } from "../../store/avatars";
export function BulkMoveModal({
ids,
@@ -16,40 +16,70 @@ export function BulkMoveModal({
}) {
const t = useT();
const slots = useFolderSlots();
const favorites = useAvatars((s) => s.favorites);
const maxPerGroup = useAvatars((s) => s.favoriteLimits.maxPerGroup);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(0);
const [moved, setMoved] = useState(0);
const [skipped, setSkipped] = useState(0);
const [confirm, setConfirm] = useState<{ folder: string; fits: number } | null>(null);
const fittingIds = (folder: string): string[] => {
const slot = slots.find((s) => s.name === folder);
const present = new Set(favorites.find((f) => f.name === folder)?.avatarIds ?? []);
const room = slot ? maxPerGroup - slot.count : ids.length;
const incoming = ids.filter((id) => !present.has(id));
const already = ids.filter((id) => present.has(id));
return [...already, ...incoming.slice(0, Math.max(room, 0))];
};
const onPick = (folder: string) => {
const fit = fittingIds(folder);
if (fit.length < ids.length) {
setConfirm({ folder, fits: fit.length });
return;
}
void move(folder);
};
const move = async (folder: string) => {
setConfirm(null);
setBusy(folder);
setError(null);
setDone(0);
setMoved(0);
setSkipped(0);
const fit = new Set(fittingIds(folder));
try {
let skippedCount = 0;
for (const id of ids) {
const result = await api.avatar.moveFavorite(id, folder);
if (result.skipped.length) skippedCount += result.skipped.length;
let movedCount = 0;
let skippedCount = ids.length - fit.size;
setSkipped(skippedCount);
for (const id of fit) {
try {
const result = await api.avatar.moveFavorite(id, folder, false);
movedCount += result.moved;
skippedCount += result.skipped.length;
} catch (err) {
if (!isRetryableApiError(err)) throw err;
skippedCount++;
}
setMoved(movedCount);
setSkipped(skippedCount);
setDone((n) => n + 1);
}
if (!skippedCount) {
onMoved();
}
await api.avatar.reloadFavorites();
if (!skippedCount) onMoved();
} catch (err) {
await api.avatar.reloadFavorites();
setError(errorMessage(err, t("avatar:actions.failed")));
} finally {
setBusy(null);
}
};
const progress = ids.length ? Math.round((done / ids.length) * 100) : 0;
return (
<Modal
open
onClose={onClose}
dismissible={busy === null}
title={t("avatar:bulk.moveTitle", { count: ids.length })}
icon={<FolderInput size={16} />}
>
@@ -57,39 +87,65 @@ export function BulkMoveModal({
{busy ? (
<div className="mb-2 flex flex-col gap-1.5">
<span className="text-[12px] text-muted">
{t("avatar:bulk.movingProgress", { done, total: ids.length })}
{t("avatar:bulk.movingProgress", { done: moved + skipped, total: ids.length })}
</span>
{skipped ? (
<p className="text-[12px] font-medium text-danger">
{t("avatar:bulk.skipped", { count: skipped })}
</p>
) : null}
<div className="h-1 overflow-hidden rounded-full bg-surface-hover">
<div
className="h-full rounded-full bg-accent transition-[width] duration-200 ease-fluid"
style={{ width: `${progress}%` }}
style={{ width: `${Math.round(((moved + skipped) / ids.length) * 100)}%` }}
/>
</div>
</div>
) : null}
{slots.map((slot) => (
<button
key={slot.name}
onClick={() => move(slot.name)}
disabled={busy !== null}
className="flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-left transition-colors hover:not-disabled:border-accent disabled:opacity-50"
>
<span className="text-faint">
<Star size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{slot.displayName}</span>
<span className="text-[11px] tabular-nums text-faint">
{t("avatar:actions.folderCount", { count: slot.count })}
{confirm ? (
<div className="mb-1 flex flex-col gap-2 rounded-lg border border-border bg-surface-2 p-3">
<p className="text-[12px] text-muted">
{t("avatar:bulk.partialConfirm", { fits: confirm.fits, total: ids.length })}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirm(null)}
className="rounded-md px-2.5 py-1 text-[12px] text-faint transition-colors hover:bg-surface-hover hover:text-text"
>
{t("common:cancel")}
</button>
<button
onClick={() => void move(confirm.folder)}
className="rounded-md bg-accent px-2.5 py-1 text-[12px] font-medium text-on-accent"
>
{t("avatar:bulk.move")}
</button>
</div>
</div>
) : null}
{slots.map((slot) => {
const full = fittingIds(slot.name).length === 0;
return (
<button
key={slot.name}
onClick={() => onPick(slot.name)}
disabled={busy !== null || full}
className="flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-left transition-colors hover:not-disabled:border-accent disabled:opacity-50"
>
<span className="text-faint">
<Star size={15} />
</span>
</span>
</button>
))}
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{slot.displayName}</span>
<span className="text-[11px] tabular-nums text-faint">
{full
? t("avatar:actions.folderFull")
: t("avatar:actions.folderCount", { count: slot.count })}
</span>
</span>
</button>
);
})}
{!busy && moved ? (
<p className="text-[12px] font-medium text-muted">
{t("avatar:bulk.moved", { count: moved })}
</p>
) : null}
{!busy && skipped ? (
<p className="text-[12px] font-medium text-danger">
{t("avatar:bulk.skipped", { count: skipped })}
@@ -58,6 +58,7 @@ export function FavoriteModal({
<Modal
open
onClose={onClose}
dismissible={busy === null}
title={currentFolder ? t("avatar:actions.manageFavorite") : t("avatar:actions.favorite")}
icon={<Star size={16} />}
>
@@ -106,6 +107,7 @@ export function FavoriteModal({
className="mt-1 justify-center"
block
loading={busy === "unfavorite"}
disabled={busy !== null}
onClick={() => run("unfavorite", () => api.avatar.unfavorite(avatar.id))}
>
{t("avatar:actions.unfavorite")}
@@ -8,9 +8,9 @@ import { useFavoriteWorlds } from "./useFavoriteWorlds";
type WorldFilter = (world: World) => boolean;
function matchWorld(query: string): WorldFilter {
function matchWorld(query: string): WorldFilter | undefined {
const q = query.trim().toLowerCase();
if (!q) return () => true;
if (!q) return undefined;
const terms = q.split(/\s+/);
return (w) => {
const haystack = `${w.name} ${w.authorName} ${w.description} ${w.tags.join(" ")}`.toLowerCase();
@@ -18,7 +18,11 @@ function matchWorld(query: string): WorldFilter {
};
}
export function WorldSearch({ children }: { children: (filter: WorldFilter) => React.ReactNode }) {
export function WorldSearch({
children,
}: {
children: (filter: WorldFilter | undefined) => React.ReactNode;
}) {
const t = useT();
const [query, setQuery] = useState("");
const filter = useMemo(() => matchWorld(query), [query]);
@@ -73,11 +77,15 @@ export function FavoriteWorldsSection({
<div className="flex flex-col gap-5">
{shown.map((folder) => (
<CollapsibleCard key={folder.name} title={folder.displayName} count={folder.worlds.length}>
<CardGrid>
{folder.worlds.map((w) => (
<WorldCard key={w.id} world={w} />
))}
</CardGrid>
{folder.worlds.length ? (
<CardGrid>
{folder.worlds.map((w) => (
<WorldCard key={w.id} world={w} />
))}
</CardGrid>
) : (
<p className="text-[13px] text-faint">{t("world:folder.empty")}</p>
)}
</CollapsibleCard>
))}
{loading ? <SkeletonGrid count={3} /> : null}
@@ -18,35 +18,38 @@ export function useFavoriteWorlds(userId: string): {
folders: FavoriteFolder[];
message?: string;
} {
const [streamed, setStreamed] = useState<FavoriteWorldFolder[] | null>(null);
const [streamed, setStreamed] = useState<{
userId: string;
folders: FavoriteWorldFolder[];
} | null>(null);
const fetched = useAsync(
() => api.world.favorites(userId),
[userId],
"Failed to load favorite worlds.",
);
useEffect(() => {
setStreamed(null);
return events.on("world:favoriteFolders", (p) => {
if (p.userId === userId) setStreamed(p.folders);
});
}, [userId]);
useEffect(
() =>
events.on("world:favoriteFolders", (p) => {
if (p.userId === userId) setStreamed({ userId, folders: p.folders });
}),
[userId],
);
const folders: FavoriteWorldFolder[] =
fetched.status === "ready" ? fetched.data : (streamed ?? []);
const fallbackFolders = useMemo<FavoriteWorldFolder[]>(() => [], []);
const streamedFolders = streamed?.userId === userId ? streamed.folders : fallbackFolders;
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)));
const assembled = useMemo<FavoriteFolder[]>(() => {
const byId = new Map(worlds.map((w) => [w.id, w]));
return folders
.map((f) => ({
name: f.name,
displayName: f.displayName,
worlds: f.worldIds.map((id) => byId.get(id)).filter((w): w is World => Boolean(w)),
}))
.filter((f) => f.worlds.length);
return folders.map((f) => ({
name: f.name,
displayName: f.displayName,
worlds: f.worldIds.map((id) => byId.get(id)).filter((w): w is World => Boolean(w)),
}));
}, [folders, worlds]);
return {
@@ -4,6 +4,7 @@ import type { World } from "../../../../shared/types/world";
import {
Button,
CardGrid,
CheckBox,
CollapsibleCard,
ContextMenu,
IconButton,
@@ -11,6 +12,7 @@ import {
SelectionBar,
SelectionBarButton,
SkeletonGrid,
Tag,
type ContextMenuEntry,
} from "../../components/ui";
import { api } from "../../lib/api";
@@ -88,6 +90,22 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
setSelecting(true);
};
const folderState = (ids: string[]) => {
const picked = ids.filter((id) => selected.has(id)).length;
return {
checked: picked > 0 && picked === ids.length,
indeterminate: picked > 0 && picked < ids.length,
};
};
const toggleFolder = (ids: string[]) =>
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));
return next;
});
const exitSelect = () => {
setSelecting(false);
setSelected(new Set());
@@ -149,13 +167,23 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
title={folder.displayName}
count={`${folder.count} / ${maxPerGroup}`}
action={
<IconButton
title={t("world:folder.edit")}
onClick={() => setEditFolder(folder)}
aria-label={t("world:folder.edit")}
>
<Pencil size={14} />
</IconButton>
<div className="flex items-center gap-2">
{selecting && folder.worlds.length ? (
<CheckBox
{...folderState(folder.worlds.map((w) => w.id))}
onChange={() => toggleFolder(folder.worlds.map((w) => w.id))}
aria-label={t("world:bulk.selectFolder", { name: folder.displayName })}
/>
) : null}
{folder.vrcPlus ? <Tag color="var(--accent)">{t("world:folder.vrcPlus")}</Tag> : null}
<IconButton
title={t("world:folder.edit")}
onClick={() => setEditFolder(folder)}
aria-label={t("world:folder.edit")}
>
<Pencil size={14} />
</IconButton>
</div>
}
>
{folder.worlds.length ? (
@@ -1,9 +1,9 @@
import { useState } from "react";
import { FolderInput, Star } from "lucide-react";
import { Modal } from "../../components/ui";
import { api, errorMessage } from "../../lib/api";
import { api, errorMessage, isRetryableApiError } from "../../lib/api";
import { useT } from "../../lib/i18n";
import { useWorldFolderSlots } from "../../store/worldFavorites";
import { useWorldFavorites, useWorldFolderSlots } from "../../store/worldFavorites";
export function WorldBulkMoveModal({
ids,
@@ -16,40 +16,70 @@ export function WorldBulkMoveModal({
}) {
const t = useT();
const slots = useWorldFolderSlots();
const groups = useWorldFavorites((s) => s.groups);
const maxPerGroup = useWorldFavorites((s) => s.limits.maxPerGroup);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(0);
const [moved, setMoved] = useState(0);
const [skipped, setSkipped] = useState(0);
const [confirm, setConfirm] = useState<{ folder: string; fits: number } | null>(null);
const fittingIds = (folder: string): string[] => {
const slot = slots.find((s) => s.name === folder);
const present = new Set(groups.find((g) => g.name === folder)?.worldIds ?? []);
const room = slot ? maxPerGroup - slot.count : ids.length;
const incoming = ids.filter((id) => !present.has(id));
const already = ids.filter((id) => present.has(id));
return [...already, ...incoming.slice(0, Math.max(room, 0))];
};
const onPick = (folder: string) => {
const fit = fittingIds(folder);
if (fit.length < ids.length) {
setConfirm({ folder, fits: fit.length });
return;
}
void move(folder);
};
const move = async (folder: string) => {
setConfirm(null);
setBusy(folder);
setError(null);
setDone(0);
setMoved(0);
setSkipped(0);
const fit = new Set(fittingIds(folder));
try {
let skippedCount = 0;
for (const id of ids) {
const result = await api.world.moveFavorite(id, folder);
if (result.skipped.length) skippedCount += result.skipped.length;
let movedCount = 0;
let skippedCount = ids.length - fit.size;
setSkipped(skippedCount);
for (const id of fit) {
try {
const result = await api.world.moveFavorite(id, folder, false);
movedCount += result.moved;
skippedCount += result.skipped.length;
} catch (err) {
if (!isRetryableApiError(err)) throw err;
skippedCount++;
}
setMoved(movedCount);
setSkipped(skippedCount);
setDone((n) => n + 1);
}
if (!skippedCount) {
onMoved();
}
await api.world.reloadFavorites();
if (!skippedCount) onMoved();
} catch (err) {
await api.world.reloadFavorites();
setError(errorMessage(err, t("world:favorite.failed")));
} finally {
setBusy(null);
}
};
const progress = ids.length ? Math.round((done / ids.length) * 100) : 0;
return (
<Modal
open
onClose={onClose}
dismissible={busy === null}
title={t("world:bulk.moveTitle", { count: ids.length })}
icon={<FolderInput size={16} />}
>
@@ -57,39 +87,65 @@ export function WorldBulkMoveModal({
{busy ? (
<div className="mb-2 flex flex-col gap-1.5">
<span className="text-[12px] text-muted">
{t("world:bulk.movingProgress", { done, total: ids.length })}
{t("world:bulk.movingProgress", { done: moved + skipped, total: ids.length })}
</span>
{skipped ? (
<p className="text-[12px] font-medium text-danger">
{t("world:bulk.skipped", { count: skipped })}
</p>
) : null}
<div className="h-1 overflow-hidden rounded-full bg-surface-hover">
<div
className="h-full rounded-full bg-accent transition-[width] duration-200 ease-fluid"
style={{ width: `${progress}%` }}
style={{ width: `${Math.round(((moved + skipped) / ids.length) * 100)}%` }}
/>
</div>
</div>
) : null}
{slots.map((slot) => (
<button
key={slot.name}
onClick={() => move(slot.name)}
disabled={busy !== null}
className="flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-left transition-colors hover:not-disabled:border-accent disabled:opacity-50"
>
<span className="text-faint">
<Star size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{slot.displayName}</span>
<span className="text-[11px] tabular-nums text-faint">
{t("world:favorite.folderCount", { count: slot.count })}
{confirm ? (
<div className="mb-1 flex flex-col gap-2 rounded-lg border border-border bg-surface-2 p-3">
<p className="text-[12px] text-muted">
{t("world:bulk.partialConfirm", { fits: confirm.fits, total: ids.length })}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirm(null)}
className="rounded-md px-2.5 py-1 text-[12px] text-faint transition-colors hover:bg-surface-hover hover:text-text"
>
{t("common:cancel")}
</button>
<button
onClick={() => void move(confirm.folder)}
className="rounded-md bg-accent px-2.5 py-1 text-[12px] font-medium text-on-accent"
>
{t("world:bulk.move")}
</button>
</div>
</div>
) : null}
{slots.map((slot) => {
const full = fittingIds(slot.name).length === 0;
return (
<button
key={slot.name}
onClick={() => onPick(slot.name)}
disabled={busy !== null || full}
className="flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-left transition-colors hover:not-disabled:border-accent disabled:opacity-50"
>
<span className="text-faint">
<Star size={15} />
</span>
</span>
</button>
))}
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{slot.displayName}</span>
<span className="text-[11px] tabular-nums text-faint">
{full
? t("world:favorite.folderFull")
: t("world:favorite.folderCount", { count: slot.count })}
</span>
</span>
</button>
);
})}
{!busy && moved ? (
<p className="text-[12px] font-medium text-muted">
{t("world:bulk.moved", { count: moved })}
</p>
) : null}
{!busy && skipped ? (
<p className="text-[12px] font-medium text-danger">
{t("world:bulk.skipped", { count: skipped })}
@@ -58,6 +58,7 @@ export function WorldFavoriteModal({
<Modal
open
onClose={onClose}
dismissible={busy === null}
title={currentFolder ? t("world:favorite.manage") : t("world:favorite.add")}
icon={<Star size={16} />}
>
@@ -106,6 +107,7 @@ export function WorldFavoriteModal({
className="mt-1 justify-center"
block
loading={busy === "unfavorite"}
disabled={busy !== null}
onClick={() => run("unfavorite", () => api.world.unfavorite(world.id))}
>
{t("world:favorite.unfavorite")}
+11 -4
View File
@@ -22,6 +22,11 @@ export function errorMessage(err: unknown, fallback: string): string {
return err instanceof ApiException ? err.error.message : fallback;
}
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";
}
type DataOf<R> = R extends { ok: true; data: infer D } ? D : never;
async function call<C extends keyof IpcRequests>(
@@ -69,10 +74,11 @@ export const api = {
snapshot: () => call("world:snapshot"),
favoritesSnapshot: () => call("world:favoritesSnapshot"),
loadFavorites: () => call("world:loadFavorites"),
reloadFavorites: () => call("world:reloadFavorites"),
favorite: (worldId: string, folder?: string) => call("world:favorite", { worldId, folder }),
unfavorite: (worldId: string) => call("world:unfavorite", worldId),
moveFavorite: (worldId: string, folder: string): Promise<WorldMoveResult> =>
call("world:moveFavorite", { worldId, folder }),
moveFavorite: (worldId: string, folder: string, reload = true): Promise<WorldMoveResult> =>
call("world:moveFavorite", { worldId, folder, reload }),
unfavoriteMany: (worldIds: string[]) => call("world:unfavoriteMany", worldIds),
moveFavoriteMany: (worldIds: string[], folder: string): Promise<WorldMoveResult> =>
call("world:moveFavoriteMany", { worldIds, folder }),
@@ -91,13 +97,14 @@ export const api = {
snapshot: () => call("avatar:snapshot"),
loadMine: () => call("avatar:loadMine"),
loadFavorites: () => call("avatar:loadFavorites"),
reloadFavorites: () => call("avatar:reloadFavorites"),
select: (avatarId: string) => call("avatar:select", avatarId),
update: (avatarId: string, edit: AvatarEdit) => call("avatar:update", { avatarId, edit }),
delete: (avatarId: string) => call("avatar:delete", avatarId),
favorite: (avatarId: string, folder?: string) => call("avatar:favorite", { avatarId, folder }),
unfavorite: (avatarId: string) => call("avatar:unfavorite", avatarId),
moveFavorite: (avatarId: string, folder: string): Promise<MoveResult> =>
call("avatar:moveFavorite", { avatarId, folder }),
moveFavorite: (avatarId: string, folder: string, reload = true): Promise<MoveResult> =>
call("avatar:moveFavorite", { avatarId, folder, reload }),
unfavoriteMany: (avatarIds: string[]) => call("avatar:unfavoriteMany", avatarIds),
moveFavoriteMany: (avatarIds: string[], folder: string): Promise<MoveResult> =>
call("avatar:moveFavoriteMany", { avatarIds, folder }),
@@ -58,6 +58,7 @@
"bulk": {
"select": "Select",
"done": "Done",
"selectFolder": "Select all in {{name}}",
"selectPrivate": "Select private",
"selectHidden": "Select hidden",
"selected": "{{count}} selected",
@@ -68,7 +69,9 @@
"unfavoriteTitle": "Remove favorites",
"unfavoriteConfirm": "Remove {{count}} selected avatars from your favorites?",
"moveTitle": "Move {{count}} avatars",
"skipped": "{{count}} couldn't be moved (private or deleted) and were left in place."
"skipped": "{{count}} couldn't be moved (private or deleted) and were left in place.",
"moved": "Moved {{count}} avatars.",
"partialConfirm": "Only {{fits}} of {{total}} will fit in this folder. Move them?"
},
"performance": {
"Excellent": "Excellent",
@@ -99,4 +102,4 @@
"avatarId": "Avatar ID"
}
}
}
}
@@ -26,7 +26,8 @@
"visibility": "Visibility",
"clear": "Empty folder",
"clearConfirm": "Remove all {{count}} worlds?",
"empty": "This folder is empty."
"empty": "This folder is empty.",
"vrcPlus": "VRC+"
},
"visibility": {
"private": "Private",
@@ -41,6 +42,7 @@
"bulk": {
"select": "Select",
"done": "Done",
"selectFolder": "Select all in {{name}}",
"selectPrivate": "Select private",
"selectDeleted": "Select deleted",
"selected": "{{count}} selected",
@@ -50,7 +52,9 @@
"unfavoriteTitle": "Remove favorites",
"unfavoriteConfirm": "Remove {{count}} selected worlds from your favorites?",
"moveTitle": "Move {{count}} worlds",
"skipped": "{{count}} couldn't be moved (private or deleted) and were left in place."
"skipped": "{{count}} couldn't be moved (private or deleted) and were left in place.",
"moved": "Moved {{count}} worlds.",
"partialConfirm": "Only {{fits}} of {{total}} will fit in this folder. Move them?"
},
"regions": {
"us": "US West",
@@ -133,4 +137,4 @@
"worldDetails": "View world details",
"instanceId": "Instance ID"
}
}
}
@@ -58,6 +58,7 @@
"bulk": {
"select": "選択",
"done": "完了",
"selectFolder": "{{name}} をすべて選択",
"selectPrivate": "非公開を選択",
"selectHidden": "Hiddenを選択",
"selected": "{{count}} 件選択中",
@@ -68,7 +69,9 @@
"unfavoriteTitle": "お気に入りから削除",
"unfavoriteConfirm": "選択した {{count}} 体をお気に入りから削除しますか?",
"moveTitle": "{{count}} 体を移動",
"skipped": "{{count}} 体は移動できず(非公開または削除済み)、そのままになりました。"
"skipped": "{{count}} 体は移動できず(非公開または削除済み)、そのままになりました。",
"moved": "{{count}} 件のアバターを移動しました。",
"partialConfirm": "{{total}} 件のうち {{fits}} 件だけがこのフォルダに入ります。移動しますか?"
},
"performance": {
"Excellent": "非常に良い",
@@ -99,4 +102,4 @@
"avatarId": "アバターID"
}
}
}
}
@@ -26,7 +26,8 @@
"visibility": "公開設定",
"clear": "フォルダを空にする",
"clearConfirm": "{{count}} 件すべて削除しますか?",
"empty": "このフォルダは空です。"
"empty": "このフォルダは空です。",
"vrcPlus": "VRC+"
},
"visibility": {
"private": "非公開",
@@ -41,6 +42,7 @@
"bulk": {
"select": "選択",
"done": "完了",
"selectFolder": "{{name}} をすべて選択",
"selectPrivate": "非公開を選択",
"selectDeleted": "削除済みを選択",
"selected": "{{count}} 件選択中",
@@ -50,7 +52,9 @@
"unfavoriteTitle": "お気に入りから削除",
"unfavoriteConfirm": "選択した {{count}} 件をお気に入りから削除しますか?",
"moveTitle": "{{count}} 件を移動",
"skipped": "{{count}} 件は移動できず(非公開または削除済み)、そのままになりました。"
"skipped": "{{count}} 件は移動できず(非公開または削除済み)、そのままになりました。",
"moved": "{{count}} 件のワールドを移動しました。",
"partialConfirm": "{{total}} 件のうち {{fits}} 件だけがこのフォルダに入ります。移動しますか?"
},
"regions": {
"us": "米国西部",
@@ -133,4 +137,4 @@
"worldDetails": "ワールドの詳細を見る",
"instanceId": "インスタンスID"
}
}
}
@@ -58,6 +58,7 @@
"bulk": {
"select": "เลือก",
"done": "เสร็จ",
"selectFolder": "เลือกทั้งหมดใน {{name}}",
"selectPrivate": "เลือกส่วนตัว",
"selectHidden": "เลือกที่ซ่อนไว้",
"selected": "เลือก {{count}} รายการ",
@@ -68,7 +69,9 @@
"unfavoriteTitle": "ลบจากรายการโปรด",
"unfavoriteConfirm": "ลบอวตารที่เลือก {{count}} ตัวออกจากรายการโปรดหรือไม่?",
"moveTitle": "ย้าย {{count}} ตัว",
"skipped": "ย้ายไม่ได้ {{count}} ตัว (ส่วนตัวหรือถูกลบแล้ว) และยังอยู่ที่เดิม"
"skipped": "ย้ายไม่ได้ {{count}} ตัว (ส่วนตัวหรือถูกลบแล้ว) และยังอยู่ที่เดิม",
"moved": "ย้าย {{count}} อวตารแล้ว",
"partialConfirm": "ใส่ได้แค่ {{fits}} จาก {{total}} ในโฟลเดอร์นี้ ย้ายเลยไหม?"
},
"performance": {
"Excellent": "ดีเยี่ยม",
@@ -99,4 +102,4 @@
"avatarId": "ไอดีอวตาร"
}
}
}
}
@@ -26,7 +26,8 @@
"visibility": "การมองเห็น",
"clear": "ล้างโฟลเดอร์",
"clearConfirm": "ลบทั้งหมด {{count}} รายการหรือไม่?",
"empty": "โฟลเดอร์นี้ว่างเปล่า"
"empty": "โฟลเดอร์นี้ว่างเปล่า",
"vrcPlus": "VRC+"
},
"visibility": {
"private": "ส่วนตัว",
@@ -41,6 +42,7 @@
"bulk": {
"select": "เลือก",
"done": "เสร็จ",
"selectFolder": "เลือกทั้งหมดใน {{name}}",
"selectPrivate": "เลือกส่วนตัว",
"selectDeleted": "เลือกที่ถูกลบ",
"selected": "เลือก {{count}} รายการ",
@@ -50,7 +52,9 @@
"unfavoriteTitle": "ลบจากรายการโปรด",
"unfavoriteConfirm": "ลบเวิลด์ที่เลือก {{count}} รายการออกจากรายการโปรดหรือไม่?",
"moveTitle": "ย้าย {{count}} รายการ",
"skipped": "ย้ายไม่ได้ {{count}} รายการ (ส่วนตัวหรือถูกลบแล้ว) และยังอยู่ที่เดิม"
"skipped": "ย้ายไม่ได้ {{count}} รายการ (ส่วนตัวหรือถูกลบแล้ว) และยังอยู่ที่เดิม",
"moved": "ย้าย {{count}} เวิลด์แล้ว",
"partialConfirm": "ใส่ได้แค่ {{fits}} จาก {{total}} ในโฟลเดอร์นี้ ย้ายเลยไหม?"
},
"regions": {
"us": "สหรัฐฯ ฝั่งตะวันตก",
+4
View File
@@ -39,6 +39,7 @@ export interface FavoriteFolder {
visibility: FavoriteWorldGroup["visibility"];
count: number;
full: boolean;
vrcPlus: boolean;
worlds: World[];
}
@@ -63,6 +64,7 @@ function toFolder(
visibility: g.visibility,
count: g.worldIds.length,
full: g.worldIds.length >= maxPerGroup,
vrcPlus: g.vrcPlus,
worlds: g.worldIds.map((id) => worlds[id]).filter((w): w is World => Boolean(w)),
};
}
@@ -81,6 +83,7 @@ export interface FolderSlot {
displayName: string;
count: number;
full: boolean;
vrcPlus: boolean;
}
export function useWorldFolderSlots(): FolderSlot[] {
@@ -93,6 +96,7 @@ export function useWorldFolderSlots(): FolderSlot[] {
displayName: g.displayName,
count: g.worldIds.length,
full: g.worldIds.length >= maxPerGroup,
vrcPlus: g.vrcPlus,
})),
[groups, maxPerGroup],
);
+12 -2
View File
@@ -68,9 +68,14 @@ export interface IpcRequests {
"world:snapshot": () => IpcResult<WorldSnapshot>;
"world:favoritesSnapshot": () => IpcResult<WorldFavoritesSnapshot>;
"world:loadFavorites": () => IpcResult<void>;
"world:reloadFavorites": () => IpcResult<void>;
"world:favorite": (p: { worldId: string; folder?: string }) => IpcResult<void>;
"world:unfavorite": (worldId: string) => IpcResult<void>;
"world:moveFavorite": (p: { worldId: string; folder: string }) => IpcResult<WorldMoveResult>;
"world:moveFavorite": (p: {
worldId: string;
folder: string;
reload?: boolean;
}) => IpcResult<WorldMoveResult>;
"world:unfavoriteMany": (worldIds: string[]) => IpcResult<void>;
"world:moveFavoriteMany": (p: {
worldIds: string[];
@@ -90,12 +95,17 @@ export interface IpcRequests {
"avatar:snapshot": () => IpcResult<AvatarSnapshot>;
"avatar:loadMine": () => IpcResult<void>;
"avatar:loadFavorites": () => IpcResult<void>;
"avatar:reloadFavorites": () => IpcResult<void>;
"avatar:select": (avatarId: string) => IpcResult<void>;
"avatar:update": (p: { avatarId: string; edit: AvatarEdit }) => IpcResult<Avatar>;
"avatar:delete": (avatarId: string) => IpcResult<void>;
"avatar:favorite": (p: { avatarId: string; folder?: string }) => IpcResult<void>;
"avatar:unfavorite": (avatarId: string) => IpcResult<void>;
"avatar:moveFavorite": (p: { avatarId: string; folder: string }) => IpcResult<MoveResult>;
"avatar:moveFavorite": (p: {
avatarId: string;
folder: string;
reload?: boolean;
}) => IpcResult<MoveResult>;
"avatar:unfavoriteMany": (avatarIds: string[]) => IpcResult<void>;
"avatar:moveFavoriteMany": (p: { avatarIds: string[]; folder: string }) => IpcResult<MoveResult>;
"avatar:clearFavoriteFolder": (folder: string) => IpcResult<void>;
+1
View File
@@ -54,6 +54,7 @@ export interface FavoriteWorldGroup {
displayName: string;
visibility: FavoriteVisibility;
worldIds: string[];
vrcPlus: boolean;
}
export interface FavoriteLimits {