From 7151a3ac9057e83ea0c9d6b23f3b252fa486878b Mon Sep 17 00:00:00 2001 From: Yuzu Date: Tue, 30 Jun 2026 20:09:20 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20improve=20favorite=20bulk?= =?UTF-8?q?=20moves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc/handlers.ts | 10 +- src/main/store/worldFavoritesStore.ts | 15 ++ src/main/vrchat/avatarService.ts | 96 +++++-- src/main/vrchat/errors.ts | 13 + src/main/vrchat/worldService.ts | 234 ++++++++++++++---- src/renderer/src/components/ui/CheckBox.tsx | 32 +++ src/renderer/src/components/ui/Modal.tsx | 27 +- src/renderer/src/components/ui/index.ts | 1 + .../src/features/avatar/AvatarsView.tsx | 40 ++- .../src/features/avatar/BulkMoveModal.tsx | 132 +++++++--- .../src/features/avatar/FavoriteModal.tsx | 2 + .../src/features/profile/WorldsSection.tsx | 24 +- .../src/features/profile/useFavoriteWorlds.ts | 35 +-- .../world/MyFavoriteWorldsSection.tsx | 42 +++- .../src/features/world/WorldBulkMoveModal.tsx | 132 +++++++--- .../src/features/world/WorldFavoriteModal.tsx | 2 + src/renderer/src/lib/api.ts | 15 +- .../src/lib/i18n/locales/en/avatar.json | 7 +- .../src/lib/i18n/locales/en/world.json | 10 +- .../src/lib/i18n/locales/ja/avatar.json | 7 +- .../src/lib/i18n/locales/ja/world.json | 10 +- .../src/lib/i18n/locales/th/avatar.json | 7 +- .../src/lib/i18n/locales/th/world.json | 8 +- src/renderer/src/store/worldFavorites.ts | 4 + src/shared/ipc.ts | 14 +- src/shared/types/world.ts | 1 + 26 files changed, 702 insertions(+), 218 deletions(-) create mode 100644 src/renderer/src/components/ui/CheckBox.tsx diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index ce7451e..c59db25 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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)), diff --git a/src/main/store/worldFavoritesStore.ts b/src/main/store/worldFavoritesStore.ts index 9b866d7..a605eb3 100644 --- a/src/main/store/worldFavoritesStore.ts +++ b/src/main/store/worldFavoritesStore.ts @@ -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 }; } diff --git a/src/main/vrchat/avatarService.ts b/src/main/vrchat/avatarService.ts index 8aaf1a2..8eb0fe0 100644 --- a/src/main/vrchat/avatarService.ts +++ b/src/main/vrchat/avatarService.ts @@ -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> = []; 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(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 { @@ -164,17 +187,28 @@ export async function unfavoriteAvatar(avatarId: string): Promise { await reloadFavorites(); } -export async function moveAvatarToFolder(avatarId: string, folder: string): Promise { +export async function moveAvatarToFolder( + avatarId: string, + folder: string, + reload = true, +): Promise { 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 { 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 { 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> { - 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 { + 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 { +export async function reloadFavorites(): Promise { userCache.invalidate(cacheKeys.avatarFavorites()); await loadFavoritedAvatars(); } diff --git a/src/main/vrchat/errors.ts b/src/main/vrchat/errors.ts index 6594cd3..f36fefa 100644 --- a/src/main/vrchat/errors.ts +++ b/src/main/vrchat/errors.ts @@ -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) }; } diff --git a/src/main/vrchat/worldService.ts b/src/main/vrchat/worldService.ts index 730efa3..7639ddf 100644 --- a/src/main/vrchat/worldService.ts +++ b/src/main/vrchat/worldService.ts @@ -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(); const members: { id: string; group: string }[] = []; const names = new Map(); + 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, ): FavoriteWorldFolder[] { - const order: string[] = []; const byGroup = new Map(); 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 { 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 { +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(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 { 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 { await reloadMyFavorites(); } -export async function moveWorldToFolder(worldId: string, folder: string): Promise { +export async function moveWorldToFolder( + worldId: string, + folder: string, + reload = true, +): Promise { 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 { 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 { 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 { + 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 { 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 { 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 { 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 { + 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[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> { - return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f.id])); +async function favoriteRecords(vrc: VRChat): Promise> { + return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f])); } -async function favoriteRecordEntries(vrc: VRChat) { +async function favoriteRecordEntries(vrc: VRChat): Promise { + 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 { +export async function reloadMyFavorites(): Promise { userCache.invalidate(cacheKeys.myFavoriteWorlds()); const me = await currentUser(); userCache.invalidate(cacheKeys.favoriteWorlds(me.id)); diff --git a/src/renderer/src/components/ui/CheckBox.tsx b/src/renderer/src/components/ui/CheckBox.tsx new file mode 100644 index 0000000..266fd5e --- /dev/null +++ b/src/renderer/src/components/ui/CheckBox.tsx @@ -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 ( + + ); +} diff --git a/src/renderer/src/components/ui/Modal.tsx b/src/renderer/src/components/ui/Modal.tsx index 00fdb7b..1d8f04c 100644 --- a/src/renderer/src/components/ui/Modal.tsx +++ b/src/renderer/src/components/ui/Modal.tsx @@ -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({ + {dismissible ? ( + + ) : null}
{children}
{onConfirm ? (
- + +
+ + ) : null} + {slots.map((slot) => { + const full = fittingIds(slot.name).length === 0; + return ( + - ))} + + {slot.displayName} + + {full + ? t("avatar:actions.folderFull") + : t("avatar:actions.folderCount", { count: slot.count })} + + + + ); + })} + {!busy && moved ? ( +

+ {t("avatar:bulk.moved", { count: moved })} +

+ ) : null} {!busy && skipped ? (

{t("avatar:bulk.skipped", { count: skipped })} diff --git a/src/renderer/src/features/avatar/FavoriteModal.tsx b/src/renderer/src/features/avatar/FavoriteModal.tsx index aae8985..6827dd2 100644 --- a/src/renderer/src/features/avatar/FavoriteModal.tsx +++ b/src/renderer/src/features/avatar/FavoriteModal.tsx @@ -58,6 +58,7 @@ export function FavoriteModal({ } > @@ -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")} diff --git a/src/renderer/src/features/profile/WorldsSection.tsx b/src/renderer/src/features/profile/WorldsSection.tsx index deb05fc..4f43d7c 100644 --- a/src/renderer/src/features/profile/WorldsSection.tsx +++ b/src/renderer/src/features/profile/WorldsSection.tsx @@ -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({

{shown.map((folder) => ( - - {folder.worlds.map((w) => ( - - ))} - + {folder.worlds.length ? ( + + {folder.worlds.map((w) => ( + + ))} + + ) : ( +

{t("world:folder.empty")}

+ )}
))} {loading ? : null} diff --git a/src/renderer/src/features/profile/useFavoriteWorlds.ts b/src/renderer/src/features/profile/useFavoriteWorlds.ts index d055bfb..fe6e69d 100644 --- a/src/renderer/src/features/profile/useFavoriteWorlds.ts +++ b/src/renderer/src/features/profile/useFavoriteWorlds.ts @@ -18,35 +18,38 @@ export function useFavoriteWorlds(userId: string): { folders: FavoriteFolder[]; message?: string; } { - const [streamed, setStreamed] = useState(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(() => [], []); + 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(() => { 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 { diff --git a/src/renderer/src/features/world/MyFavoriteWorldsSection.tsx b/src/renderer/src/features/world/MyFavoriteWorldsSection.tsx index 8241907..f54169a 100644 --- a/src/renderer/src/features/world/MyFavoriteWorldsSection.tsx +++ b/src/renderer/src/features/world/MyFavoriteWorldsSection.tsx @@ -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={ - setEditFolder(folder)} - aria-label={t("world:folder.edit")} - > - - +
+ {selecting && folder.worlds.length ? ( + w.id))} + onChange={() => toggleFolder(folder.worlds.map((w) => w.id))} + aria-label={t("world:bulk.selectFolder", { name: folder.displayName })} + /> + ) : null} + {folder.vrcPlus ? {t("world:folder.vrcPlus")} : null} + setEditFolder(folder)} + aria-label={t("world:folder.edit")} + > + + +
} > {folder.worlds.length ? ( diff --git a/src/renderer/src/features/world/WorldBulkMoveModal.tsx b/src/renderer/src/features/world/WorldBulkMoveModal.tsx index 9974132..789d999 100644 --- a/src/renderer/src/features/world/WorldBulkMoveModal.tsx +++ b/src/renderer/src/features/world/WorldBulkMoveModal.tsx @@ -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(null); const [error, setError] = useState(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 ( } > @@ -57,39 +87,65 @@ export function WorldBulkMoveModal({ {busy ? (
- {t("world:bulk.movingProgress", { done, total: ids.length })} + {t("world:bulk.movingProgress", { done: moved + skipped, total: ids.length })} - {skipped ? ( -

- {t("world:bulk.skipped", { count: skipped })} -

- ) : null}
) : null} - {slots.map((slot) => ( - + +
+
+ ) : null} + {slots.map((slot) => { + const full = fittingIds(slot.name).length === 0; + return ( + - ))} + + {slot.displayName} + + {full + ? t("world:favorite.folderFull") + : t("world:favorite.folderCount", { count: slot.count })} + + + + ); + })} + {!busy && moved ? ( +

+ {t("world:bulk.moved", { count: moved })} +

+ ) : null} {!busy && skipped ? (

{t("world:bulk.skipped", { count: skipped })} diff --git a/src/renderer/src/features/world/WorldFavoriteModal.tsx b/src/renderer/src/features/world/WorldFavoriteModal.tsx index 362079b..42efeb2 100644 --- a/src/renderer/src/features/world/WorldFavoriteModal.tsx +++ b/src/renderer/src/features/world/WorldFavoriteModal.tsx @@ -58,6 +58,7 @@ export function WorldFavoriteModal({ } > @@ -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")} diff --git a/src/renderer/src/lib/api.ts b/src/renderer/src/lib/api.ts index e697a8e..79e97af 100644 --- a/src/renderer/src/lib/api.ts +++ b/src/renderer/src/lib/api.ts @@ -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 extends { ok: true; data: infer D } ? D : never; async function call( @@ -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 => - call("world:moveFavorite", { worldId, folder }), + moveFavorite: (worldId: string, folder: string, reload = true): Promise => + call("world:moveFavorite", { worldId, folder, reload }), unfavoriteMany: (worldIds: string[]) => call("world:unfavoriteMany", worldIds), moveFavoriteMany: (worldIds: string[], folder: string): Promise => 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 => - call("avatar:moveFavorite", { avatarId, folder }), + moveFavorite: (avatarId: string, folder: string, reload = true): Promise => + call("avatar:moveFavorite", { avatarId, folder, reload }), unfavoriteMany: (avatarIds: string[]) => call("avatar:unfavoriteMany", avatarIds), moveFavoriteMany: (avatarIds: string[], folder: string): Promise => call("avatar:moveFavoriteMany", { avatarIds, folder }), diff --git a/src/renderer/src/lib/i18n/locales/en/avatar.json b/src/renderer/src/lib/i18n/locales/en/avatar.json index 0cdf0ae..c2d6b71 100644 --- a/src/renderer/src/lib/i18n/locales/en/avatar.json +++ b/src/renderer/src/lib/i18n/locales/en/avatar.json @@ -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" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/lib/i18n/locales/en/world.json b/src/renderer/src/lib/i18n/locales/en/world.json index f6f3f86..f545727 100644 --- a/src/renderer/src/lib/i18n/locales/en/world.json +++ b/src/renderer/src/lib/i18n/locales/en/world.json @@ -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" } -} +} \ No newline at end of file diff --git a/src/renderer/src/lib/i18n/locales/ja/avatar.json b/src/renderer/src/lib/i18n/locales/ja/avatar.json index 749a65a..2bbdc76 100644 --- a/src/renderer/src/lib/i18n/locales/ja/avatar.json +++ b/src/renderer/src/lib/i18n/locales/ja/avatar.json @@ -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" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/lib/i18n/locales/ja/world.json b/src/renderer/src/lib/i18n/locales/ja/world.json index 940d9fb..dd5dd3b 100644 --- a/src/renderer/src/lib/i18n/locales/ja/world.json +++ b/src/renderer/src/lib/i18n/locales/ja/world.json @@ -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" } -} +} \ No newline at end of file diff --git a/src/renderer/src/lib/i18n/locales/th/avatar.json b/src/renderer/src/lib/i18n/locales/th/avatar.json index 5175b50..ba99c03 100644 --- a/src/renderer/src/lib/i18n/locales/th/avatar.json +++ b/src/renderer/src/lib/i18n/locales/th/avatar.json @@ -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": "ไอดีอวตาร" } } -} +} \ No newline at end of file diff --git a/src/renderer/src/lib/i18n/locales/th/world.json b/src/renderer/src/lib/i18n/locales/th/world.json index a5be720..2c4600d 100644 --- a/src/renderer/src/lib/i18n/locales/th/world.json +++ b/src/renderer/src/lib/i18n/locales/th/world.json @@ -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": "สหรัฐฯ ฝั่งตะวันตก", diff --git a/src/renderer/src/store/worldFavorites.ts b/src/renderer/src/store/worldFavorites.ts index e9b5ad5..e1f9a28 100644 --- a/src/renderer/src/store/worldFavorites.ts +++ b/src/renderer/src/store/worldFavorites.ts @@ -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], ); diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a453308..84f9df7 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -68,9 +68,14 @@ export interface IpcRequests { "world:snapshot": () => IpcResult; "world:favoritesSnapshot": () => IpcResult; "world:loadFavorites": () => IpcResult; + "world:reloadFavorites": () => IpcResult; "world:favorite": (p: { worldId: string; folder?: string }) => IpcResult; "world:unfavorite": (worldId: string) => IpcResult; - "world:moveFavorite": (p: { worldId: string; folder: string }) => IpcResult; + "world:moveFavorite": (p: { + worldId: string; + folder: string; + reload?: boolean; + }) => IpcResult; "world:unfavoriteMany": (worldIds: string[]) => IpcResult; "world:moveFavoriteMany": (p: { worldIds: string[]; @@ -90,12 +95,17 @@ export interface IpcRequests { "avatar:snapshot": () => IpcResult; "avatar:loadMine": () => IpcResult; "avatar:loadFavorites": () => IpcResult; + "avatar:reloadFavorites": () => IpcResult; "avatar:select": (avatarId: string) => IpcResult; "avatar:update": (p: { avatarId: string; edit: AvatarEdit }) => IpcResult; "avatar:delete": (avatarId: string) => IpcResult; "avatar:favorite": (p: { avatarId: string; folder?: string }) => IpcResult; "avatar:unfavorite": (avatarId: string) => IpcResult; - "avatar:moveFavorite": (p: { avatarId: string; folder: string }) => IpcResult; + "avatar:moveFavorite": (p: { + avatarId: string; + folder: string; + reload?: boolean; + }) => IpcResult; "avatar:unfavoriteMany": (avatarIds: string[]) => IpcResult; "avatar:moveFavoriteMany": (p: { avatarIds: string[]; folder: string }) => IpcResult; "avatar:clearFavoriteFolder": (folder: string) => IpcResult; diff --git a/src/shared/types/world.ts b/src/shared/types/world.ts index 52279a3..57f7b04 100644 --- a/src/shared/types/world.ts +++ b/src/shared/types/world.ts @@ -54,6 +54,7 @@ export interface FavoriteWorldGroup { displayName: string; visibility: FavoriteVisibility; worldIds: string[]; + vrcPlus: boolean; } export interface FavoriteLimits {