mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +00:00
✨ feat: improve favorite bulk moves
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
try {
|
||||
await vrc.addFavorite({
|
||||
body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
|
||||
throwOnError: true,
|
||||
});
|
||||
await reloadFavorites();
|
||||
} 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 });
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
+177
-41
@@ -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,
|
||||
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 });
|
||||
try {
|
||||
await vrc.addFavorite({
|
||||
body: { type: "world", favoriteId: worldId, tags: [folder] },
|
||||
body: favoriteBody(type, worldId, [folder]),
|
||||
throwOnError: true,
|
||||
});
|
||||
await reloadMyFavorites();
|
||||
} 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 });
|
||||
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: { type: "world", favoriteId: id, tags: [folder] },
|
||||
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) {
|
||||
const pageSize = 100;
|
||||
const entries = [];
|
||||
for (let offset = 0; ; offset += pageSize) {
|
||||
const { data } = await vrc.getFavorites({
|
||||
query: { type: "world", n: pageSize, offset },
|
||||
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,
|
||||
});
|
||||
entries.push(...data);
|
||||
if (data.length < pageSize) return entries;
|
||||
const groups = rawGroups.filter((g) => isWorldGroupType(g.type));
|
||||
const pageSize = 100;
|
||||
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,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,6 +69,7 @@ export function Modal({
|
||||
{icon}
|
||||
{title}
|
||||
</h2>
|
||||
{dismissible ? (
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
@@ -73,13 +77,14 @@ export function Modal({
|
||||
>
|
||||
<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
|
||||
|
||||
@@ -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,6 +447,14 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
|
||||
title={folder.displayName}
|
||||
count={`${folder.count} / ${maxPerGroup}`}
|
||||
action={
|
||||
<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)}
|
||||
@@ -437,6 +462,7 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
|
||||
>
|
||||
<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);
|
||||
setDone((n) => n + 1);
|
||||
}
|
||||
if (!skippedCount) {
|
||||
onMoved();
|
||||
}
|
||||
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);
|
||||
}
|
||||
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,26 +87,44 @@ 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) => (
|
||||
{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={() => move(slot.name)}
|
||||
disabled={busy !== null}
|
||||
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">
|
||||
@@ -85,11 +133,19 @@ export function BulkMoveModal({
|
||||
<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 })}
|
||||
{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}>
|
||||
{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) => ({
|
||||
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);
|
||||
}));
|
||||
}, [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,6 +167,15 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
|
||||
title={folder.displayName}
|
||||
count={`${folder.count} / ${maxPerGroup}`}
|
||||
action={
|
||||
<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)}
|
||||
@@ -156,6 +183,7 @@ export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
|
||||
>
|
||||
<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);
|
||||
setDone((n) => n + 1);
|
||||
}
|
||||
if (!skippedCount) {
|
||||
onMoved();
|
||||
}
|
||||
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);
|
||||
}
|
||||
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,26 +87,44 @@ 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) => (
|
||||
{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={() => move(slot.name)}
|
||||
disabled={busy !== null}
|
||||
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">
|
||||
@@ -85,11 +133,19 @@ export function WorldBulkMoveModal({
|
||||
<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 })}
|
||||
{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")}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "非常に良い",
|
||||
|
||||
@@ -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": "米国西部",
|
||||
|
||||
@@ -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": "ดีเยี่ยม",
|
||||
|
||||
@@ -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": "สหรัฐฯ ฝั่งตะวันตก",
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface FavoriteWorldGroup {
|
||||
displayName: string;
|
||||
visibility: FavoriteVisibility;
|
||||
worldIds: string[];
|
||||
vrcPlus: boolean;
|
||||
}
|
||||
|
||||
export interface FavoriteLimits {
|
||||
|
||||
Reference in New Issue
Block a user