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