mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +00:00
✨ feat: Manage world favorites
This commit is contained in:
Vendored
+1
@@ -31,6 +31,7 @@ export const cacheKeys = {
|
|||||||
friends: () => "friends",
|
friends: () => "friends",
|
||||||
userWorlds: (id: string) => `user:worlds:${id}`,
|
userWorlds: (id: string) => `user:worlds:${id}`,
|
||||||
favoriteWorlds: (id: string) => `worlds:favorites:${id}`,
|
favoriteWorlds: (id: string) => `worlds:favorites:${id}`,
|
||||||
|
myFavoriteWorlds: () => "worlds:favorites:mine",
|
||||||
world: (id: string) => `world:${id}`,
|
world: (id: string) => `world:${id}`,
|
||||||
avatar: (id: string) => `avatar:${id}`,
|
avatar: (id: string) => `avatar:${id}`,
|
||||||
avatarFavorites: () => "avatar:favorites",
|
avatarFavorites: () => "avatar:favorites",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import * as game from "../game/launch";
|
|||||||
import * as region from "../game/region";
|
import * as region from "../game/region";
|
||||||
import { socialSnapshot } from "../store/social";
|
import { socialSnapshot } from "../store/social";
|
||||||
import { worldStore } from "../store/worldStore";
|
import { worldStore } from "../store/worldStore";
|
||||||
|
import { worldFavoritesStore } from "../store/worldFavoritesStore";
|
||||||
import { groupStore } from "../store/groupStore";
|
import { groupStore } from "../store/groupStore";
|
||||||
import { openDebugWindow } from "../windows";
|
import { openDebugWindow } from "../windows";
|
||||||
|
|
||||||
@@ -53,6 +54,18 @@ const handlers = {
|
|||||||
"world:discover": () => guard(() => worlds.getDiscover()),
|
"world:discover": () => guard(() => worlds.getDiscover()),
|
||||||
"world:get": (worldId) => guard(() => worlds.getWorld(worldId)),
|
"world:get": (worldId) => guard(() => worlds.getWorld(worldId)),
|
||||||
"world:snapshot": () => guard(async () => worldStore.snapshot()),
|
"world:snapshot": () => guard(async () => worldStore.snapshot()),
|
||||||
|
"world:favoritesSnapshot": () => guard(async () => worldFavoritesStore.snapshot()),
|
||||||
|
"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:unfavoriteMany": (worldIds) => guard(() => worlds.unfavoriteWorlds(worldIds)),
|
||||||
|
"world:moveFavoriteMany": ({ worldIds, folder }) =>
|
||||||
|
guard(() => worlds.moveWorldsToFolder(worldIds, folder)),
|
||||||
|
"world:clearFavoriteFolder": (folder) => guard(() => worlds.clearFavoriteWorldFolder(folder)),
|
||||||
|
"world:updateFavoriteFolder": ({ folder, edit }) =>
|
||||||
|
guard(() => worlds.updateFavoriteWorldFolder(folder, edit)),
|
||||||
|
|
||||||
"instance:get": ({ worldId, instanceId }) =>
|
"instance:get": ({ worldId, instanceId }) =>
|
||||||
guard(() => instances.getInstance(worldId, instanceId)),
|
guard(() => instances.getInstance(worldId, instanceId)),
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ export class Repository<T extends Entity> {
|
|||||||
const prev = fields[key];
|
const prev = fields[key];
|
||||||
const cls = this.policy.classOf(key);
|
const cls = this.policy.classOf(key);
|
||||||
if (prev) {
|
if (prev) {
|
||||||
|
// a stripped "???" list entry shouldn't wipe an identity field we already resolved
|
||||||
|
if (cls === "identity" && isEmpty(incoming) && !isEmpty(data[key])) continue;
|
||||||
if (
|
if (
|
||||||
this.policy.keepNonEmpty?.has(key) &&
|
this.policy.keepNonEmpty?.has(key) &&
|
||||||
isEmpty(incoming) &&
|
isEmpty(incoming) &&
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { entityStore, type SocialSnapshot } from "./entityStore";
|
|||||||
import { worldStore } from "./worldStore";
|
import { worldStore } from "./worldStore";
|
||||||
import { groupStore } from "./groupStore";
|
import { groupStore } from "./groupStore";
|
||||||
import { avatarStore } from "./avatarStore";
|
import { avatarStore } from "./avatarStore";
|
||||||
|
import { worldFavoritesStore } from "./worldFavoritesStore";
|
||||||
import { repos } from "./repository/manager";
|
import { repos } from "./repository/manager";
|
||||||
import { broadcast } from "../windows";
|
import { broadcast } from "../windows";
|
||||||
import { logger } from "../debug/logger";
|
import { logger } from "../debug/logger";
|
||||||
@@ -172,6 +173,7 @@ export async function seedActiveAccount(force = false): Promise<void> {
|
|||||||
repos.setActive(id);
|
repos.setActive(id);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
worldStore.reset();
|
worldStore.reset();
|
||||||
|
worldFavoritesStore.reset();
|
||||||
groupStore.reset();
|
groupStore.reset();
|
||||||
avatarStore.reset();
|
avatarStore.reset();
|
||||||
entityStore.reset();
|
entityStore.reset();
|
||||||
@@ -180,6 +182,7 @@ export async function seedActiveAccount(force = false): Promise<void> {
|
|||||||
if (!force && alreadyActive) return;
|
if (!force && alreadyActive) return;
|
||||||
|
|
||||||
worldStore.reset();
|
worldStore.reset();
|
||||||
|
worldFavoritesStore.reset();
|
||||||
groupStore.reset();
|
groupStore.reset();
|
||||||
entityStore.reset();
|
entityStore.reset();
|
||||||
|
|
||||||
@@ -219,4 +222,6 @@ export function startSocialBridge(): void {
|
|||||||
if (c.type === "seed") broadcast("avatar:seed", c.snapshot);
|
if (c.type === "seed") broadcast("avatar:seed", c.snapshot);
|
||||||
else broadcast("avatar:upsert", c.avatar);
|
else broadcast("avatar:upsert", c.avatar);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
worldFavoritesStore.onChange((snap) => broadcast("world:favorites:seed", snap));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type {
|
||||||
|
FavoriteLimits,
|
||||||
|
FavoriteVisibility,
|
||||||
|
FavoriteWorldGroup,
|
||||||
|
World,
|
||||||
|
WorldFavoritesSnapshot,
|
||||||
|
} from "../../shared/types/world";
|
||||||
|
import { worldStore } from "./worldStore";
|
||||||
|
|
||||||
|
const DEFAULT_LIMITS: FavoriteLimits = { maxGroups: 4, maxPerGroup: 64 };
|
||||||
|
|
||||||
|
export type FavoriteGroupInput = {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
visibility: FavoriteVisibility;
|
||||||
|
worlds: World[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Listener = (snapshot: WorldFavoritesSnapshot) => void;
|
||||||
|
|
||||||
|
class WorldFavoritesStore {
|
||||||
|
private readonly listeners = new Set<Listener>();
|
||||||
|
private groups: FavoriteWorldGroup[] = [];
|
||||||
|
private limits: FavoriteLimits = DEFAULT_LIMITS;
|
||||||
|
|
||||||
|
onChange(fn: Listener): () => void {
|
||||||
|
this.listeners.add(fn);
|
||||||
|
return () => this.listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFavorites(groups: FavoriteGroupInput[], limits?: FavoriteLimits): void {
|
||||||
|
this.groups = groups.map((g) => ({
|
||||||
|
name: g.name,
|
||||||
|
displayName: g.displayName,
|
||||||
|
visibility: g.visibility,
|
||||||
|
worldIds: g.worlds.map((w) => w.id),
|
||||||
|
}));
|
||||||
|
if (limits) this.limits = limits;
|
||||||
|
for (const g of groups) for (const w of g.worlds) worldStore.addWorld(w);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): WorldFavoritesSnapshot {
|
||||||
|
return { groups: this.groups, limits: this.limits };
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.groups = [];
|
||||||
|
this.limits = DEFAULT_LIMITS;
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(): void {
|
||||||
|
const snap = this.snapshot();
|
||||||
|
for (const fn of this.listeners) fn(snap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const worldFavoritesStore = new WorldFavoritesStore();
|
||||||
@@ -164,15 +164,18 @@ export function toCurrentUserSummary(raw: RawUser): CurrentUserSummary {
|
|||||||
|
|
||||||
type RawWorld = SdkWorld | LimitedWorld | FavoritedWorld;
|
type RawWorld = SdkWorld | LimitedWorld | FavoritedWorld;
|
||||||
|
|
||||||
|
// vrchat returns "???" for the name/author of a private world you can't read
|
||||||
|
const unhide = (v: string | undefined): string => (v && v !== "???" ? v : "");
|
||||||
|
|
||||||
export function toWorld(raw: RawWorld): World {
|
export function toWorld(raw: RawWorld): World {
|
||||||
const platforms = raw.unityPackages ? platformsOf(raw.unityPackages) : undefined;
|
const platforms = raw.unityPackages ? platformsOf(raw.unityPackages) : undefined;
|
||||||
const detailed = "visits" in raw;
|
const detailed = "visits" in raw;
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
detailed,
|
detailed,
|
||||||
name: raw.name,
|
name: unhide(raw.name),
|
||||||
authorId: raw.authorId ?? "",
|
authorId: raw.authorId ?? "",
|
||||||
authorName: raw.authorName,
|
authorName: unhide(raw.authorName),
|
||||||
description: "description" in raw ? (raw.description ?? "") : "",
|
description: "description" in raw ? (raw.description ?? "") : "",
|
||||||
imageUrl: raw.imageUrl ?? "",
|
imageUrl: raw.imageUrl ?? "",
|
||||||
thumbnailImageUrl: raw.thumbnailImageUrl ?? "",
|
thumbnailImageUrl: raw.thumbnailImageUrl ?? "",
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { VRChat } from "vrchat";
|
import type { VRChat } from "vrchat";
|
||||||
import type { DiscoverCategory, FavoriteWorldFolder, World } from "../../shared/types/world";
|
import type {
|
||||||
|
DiscoverCategory,
|
||||||
|
FavoriteGroupEdit,
|
||||||
|
FavoriteLimits,
|
||||||
|
FavoriteVisibility,
|
||||||
|
FavoriteWorldFolder,
|
||||||
|
MoveResult,
|
||||||
|
World,
|
||||||
|
} from "../../shared/types/world";
|
||||||
import { httpStatusOf } from "./errors";
|
import { httpStatusOf } from "./errors";
|
||||||
import {
|
import {
|
||||||
getCategoryWorlds,
|
getCategoryWorlds,
|
||||||
@@ -9,17 +17,26 @@ import {
|
|||||||
} from "./rawEndpoints";
|
} from "./rawEndpoints";
|
||||||
import { toWorld } from "./mappers";
|
import { toWorld } from "./mappers";
|
||||||
import { cachedRead } from "./cachedRead";
|
import { cachedRead } from "./cachedRead";
|
||||||
|
import { requireActiveClient } from "./client";
|
||||||
|
import { userCache, currentUser } from "./userService";
|
||||||
import { worldStore } from "../store/worldStore";
|
import { worldStore } from "../store/worldStore";
|
||||||
|
import { worldFavoritesStore, type FavoriteGroupInput } from "../store/worldFavoritesStore";
|
||||||
import { broadcast } from "../windows";
|
import { broadcast } from "../windows";
|
||||||
import { cacheKeys, policies } from "../cache/policies";
|
import { cacheKeys, policies } from "../cache/policies";
|
||||||
|
|
||||||
export async function getWorld(worldId: string): Promise<World> {
|
export async function getWorld(worldId: string): Promise<World> {
|
||||||
const world = await cachedRead(cacheKeys.world(worldId), policies.world, async (vrc) => {
|
try {
|
||||||
const { data } = await vrc.getWorld({ path: { worldId }, throwOnError: true });
|
const world = await cachedRead(cacheKeys.world(worldId), policies.world, async (vrc) => {
|
||||||
return toWorld(data);
|
const { data } = await vrc.getWorld({ path: { worldId }, throwOnError: true });
|
||||||
});
|
return toWorld(data);
|
||||||
worldStore.addWorld(world);
|
});
|
||||||
return world;
|
worldStore.addWorld(world);
|
||||||
|
return world;
|
||||||
|
} catch (err) {
|
||||||
|
const fallback = worldStore.get(worldId);
|
||||||
|
if (fallback) return fallback;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CachedFavorites = { worlds: World[]; folders: FavoriteWorldFolder[] };
|
type CachedFavorites = { worlds: World[]; folders: FavoriteWorldFolder[] };
|
||||||
@@ -156,6 +173,212 @@ async function loadCategory(
|
|||||||
return { id: cat.id, name: cat.name, worlds: raw.map(toWorld) };
|
return { id: cat.id, name: cat.name, worlds: raw.map(toWorld) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FOLDER = "worlds1";
|
||||||
|
|
||||||
|
export async function loadMyFavoriteWorlds(): Promise<void> {
|
||||||
|
const me = await currentUser();
|
||||||
|
const { groups, limits } = await cachedRead(
|
||||||
|
cacheKeys.myFavoriteWorlds(),
|
||||||
|
policies.favoriteWorlds,
|
||||||
|
(vrc) => fetchMyFavorites(vrc, me.id),
|
||||||
|
);
|
||||||
|
worldFavoritesStore.setFavorites(groups, limits);
|
||||||
|
void resolveHiddenFavorites(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the favorites listing returns private worlds as "???"; the detail read fills them in, or 404s if deleted
|
||||||
|
async function resolveHiddenFavorites(groups: FavoriteGroupInput[]): Promise<void> {
|
||||||
|
const vrc = requireActiveClient();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const world of group.worlds) {
|
||||||
|
if (world.name || seen.has(world.id)) continue;
|
||||||
|
seen.add(world.id);
|
||||||
|
try {
|
||||||
|
const { data } = await vrc.getWorld({ path: { worldId: world.id }, throwOnError: true });
|
||||||
|
worldStore.addWorld(toWorld(data));
|
||||||
|
} catch (err) {
|
||||||
|
if (httpStatusOf(err) === 404) worldStore.addWorld({ ...world, deleted: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMyFavorites(
|
||||||
|
vrc: VRChat,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ groups: FavoriteGroupInput[]; limits: FavoriteLimits }> {
|
||||||
|
const [{ data: rawGroups }, limits] = 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[] = [];
|
||||||
|
for (const group of worldGroups) {
|
||||||
|
let worlds: World[] = [];
|
||||||
|
try {
|
||||||
|
const raw = await getFavoriteGroupWorlds(
|
||||||
|
vrc,
|
||||||
|
group.type as WorldFavoriteGroupType,
|
||||||
|
group.name,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
worlds = raw.map(toWorld);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isPrivateFavorites(err)) throw err;
|
||||||
|
}
|
||||||
|
groups.push({
|
||||||
|
name: group.name,
|
||||||
|
displayName: group.displayName || prettyFolderName(group.name),
|
||||||
|
visibility: normalizeVisibility(group.visibility),
|
||||||
|
worlds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { groups, limits };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
|
||||||
|
const { data } = await vrc.getFavoriteLimits({ throwOnError: true });
|
||||||
|
return {
|
||||||
|
maxGroups: data.maxFavoriteGroups?.world ?? data.defaultMaxFavoriteGroups,
|
||||||
|
maxPerGroup: data.maxFavoritesPerGroup?.world ?? data.defaultMaxFavoritesPerGroup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVisibility(v: string): FavoriteVisibility {
|
||||||
|
return v === "friends" || v === "public" ? v : "private";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function favoriteWorld(worldId: string, folder = DEFAULT_FOLDER): Promise<void> {
|
||||||
|
const vrc = requireActiveClient();
|
||||||
|
await vrc.addFavorite({
|
||||||
|
body: { type: "world", favoriteId: worldId, tags: [folder] },
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
await reloadMyFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unfavoriteWorld(worldId: string): Promise<void> {
|
||||||
|
const vrc = requireActiveClient();
|
||||||
|
const fav = await findFavoriteRecord(vrc, worldId);
|
||||||
|
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
||||||
|
await reloadMyFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveWorldToFolder(worldId: string, folder: string): Promise<MoveResult> {
|
||||||
|
const vrc = requireActiveClient();
|
||||||
|
const fav = await findFavoriteRecord(vrc, worldId);
|
||||||
|
if (fav?.tags?.includes(folder)) return { moved: 0, skipped: [] };
|
||||||
|
if (!(await canRefavorite(vrc, worldId))) return { moved: 0, skipped: [worldId] };
|
||||||
|
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();
|
||||||
|
return { moved: 1, skipped: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unfavoriteWorlds(worldIds: string[]): Promise<void> {
|
||||||
|
const vrc = requireActiveClient();
|
||||||
|
const records = await favoriteRecords(vrc);
|
||||||
|
for (const id of worldIds) {
|
||||||
|
const recordId = records.get(id);
|
||||||
|
if (recordId) await vrc.removeFavorite({ path: { favoriteId: recordId }, throwOnError: true });
|
||||||
|
}
|
||||||
|
await reloadMyFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveWorldsToFolder(
|
||||||
|
worldIds: string[],
|
||||||
|
folder: string,
|
||||||
|
): Promise<MoveResult> {
|
||||||
|
const vrc = requireActiveClient();
|
||||||
|
const records = await favoriteRecords(vrc);
|
||||||
|
const skipped: string[] = [];
|
||||||
|
let moved = 0;
|
||||||
|
for (const id of worldIds) {
|
||||||
|
if (!(await canRefavorite(vrc, id))) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
moved++;
|
||||||
|
}
|
||||||
|
await reloadMyFavorites();
|
||||||
|
return { moved, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearFavoriteWorldFolder(folder: string): Promise<void> {
|
||||||
|
const vrc = requireActiveClient();
|
||||||
|
const me = await currentUser();
|
||||||
|
await vrc.clearFavoriteGroup({
|
||||||
|
path: { favoriteGroupType: "world", favoriteGroupName: folder, userId: me.id },
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
await reloadMyFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFavoriteWorldFolder(
|
||||||
|
folder: string,
|
||||||
|
edit: FavoriteGroupEdit,
|
||||||
|
): Promise<void> {
|
||||||
|
const vrc = requireActiveClient();
|
||||||
|
const me = await currentUser();
|
||||||
|
await vrc.updateFavoriteGroup({
|
||||||
|
path: { favoriteGroupType: "world", favoriteGroupName: folder, userId: me.id },
|
||||||
|
body: {
|
||||||
|
displayName: edit.displayName,
|
||||||
|
visibility: edit.visibility as never,
|
||||||
|
},
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
await reloadMyFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canRefavorite(vrc: VRChat, worldId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await vrc.getWorld({ path: { worldId }, throwOnError: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFavoriteRecord(vrc: VRChat, worldId: string) {
|
||||||
|
return (await favoriteRecordEntries(vrc)).find((f) => f.favoriteId === worldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function favoriteRecords(vrc: VRChat): Promise<Map<string, string>> {
|
||||||
|
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f.id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function favoriteRecordEntries(vrc: VRChat) {
|
||||||
|
const pageSize = 100;
|
||||||
|
const entries = [];
|
||||||
|
for (let offset = 0; ; offset += pageSize) {
|
||||||
|
const { data } = await vrc.getFavorites({
|
||||||
|
query: { type: "world", n: pageSize, offset },
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
entries.push(...data);
|
||||||
|
if (data.length < pageSize) return entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadMyFavorites(): Promise<void> {
|
||||||
|
userCache.invalidate(cacheKeys.myFavoriteWorlds());
|
||||||
|
const me = await currentUser();
|
||||||
|
userCache.invalidate(cacheKeys.favoriteWorlds(me.id));
|
||||||
|
await loadMyFavoriteWorlds();
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserWorlds(userId: string, isSelf: boolean): Promise<World[]> {
|
export async function getUserWorlds(userId: string, isSelf: boolean): Promise<World[]> {
|
||||||
const worlds = await cachedRead(
|
const worlds = await cachedRead(
|
||||||
cacheKeys.userWorlds(userId),
|
cacheKeys.userWorlds(userId),
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { Banner, Loader, PAGE_TITLE, Tabs } from "../../components/ui";
|
|||||||
import { useT } from "../../lib/i18n";
|
import { useT } from "../../lib/i18n";
|
||||||
import { useViewState } from "../navigation/NavContext";
|
import { useViewState } from "../navigation/NavContext";
|
||||||
import { useProfile } from "./useProfile";
|
import { useProfile } from "./useProfile";
|
||||||
import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection";
|
import { WorldsSection, WorldSearch } from "./WorldsSection";
|
||||||
import { DiscoverSection } from "./DiscoverSection";
|
import { DiscoverSection } from "./DiscoverSection";
|
||||||
|
import { MyFavoriteWorldsSection } from "../world/MyFavoriteWorldsSection";
|
||||||
|
|
||||||
const SHELL = "mx-auto flex w-full max-w-[1100px] flex-col gap-5 px-12 pb-16 pt-10";
|
const SHELL = "mx-auto flex w-full max-w-[1100px] flex-col gap-5 px-12 pb-16 pt-10";
|
||||||
|
|
||||||
@@ -49,9 +50,7 @@ export function MyWorldsView() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rise-in">
|
<div className="rise-in">
|
||||||
<WorldSearch>
|
<WorldSearch>{(filter) => <MyFavoriteWorldsSection filter={filter} />}</WorldSearch>
|
||||||
{(filter) => <FavoriteWorldsSection userId={userId} filter={filter} />}
|
|
||||||
</WorldSearch>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { CheckSquare, Eye, FolderInput, Pencil, Star, Trash2, X } from "lucide-react";
|
||||||
|
import type { World } from "../../../../shared/types/world";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
CardGrid,
|
||||||
|
CollapsibleCard,
|
||||||
|
ContextMenu,
|
||||||
|
IconButton,
|
||||||
|
Modal,
|
||||||
|
SelectionBar,
|
||||||
|
SelectionBarButton,
|
||||||
|
SkeletonGrid,
|
||||||
|
type ContextMenuEntry,
|
||||||
|
} from "../../components/ui";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { useT } from "../../lib/i18n";
|
||||||
|
import {
|
||||||
|
useFavoriteWorldFolders,
|
||||||
|
useWorldFavoriteLimits,
|
||||||
|
type FavoriteFolder,
|
||||||
|
} from "../../store/worldFavorites";
|
||||||
|
import { useSocial } from "../../store/social";
|
||||||
|
import { useNav } from "../navigation/NavContext";
|
||||||
|
import { WorldCard } from "./WorldCard";
|
||||||
|
import { WorldFavoriteModal } from "./WorldFavoriteModal";
|
||||||
|
import { WorldFolderEditModal } from "./WorldFolderEditModal";
|
||||||
|
import { WorldBulkMoveModal } from "./WorldBulkMoveModal";
|
||||||
|
|
||||||
|
type WorldFilter = (world: World) => boolean;
|
||||||
|
|
||||||
|
export function MyFavoriteWorldsSection({ filter }: { filter?: WorldFilter }) {
|
||||||
|
const t = useT();
|
||||||
|
const nav = useNav();
|
||||||
|
const selfId = useSocial((s) => s.selfId);
|
||||||
|
const folders = useFavoriteWorldFolders();
|
||||||
|
const { maxPerGroup } = useWorldFavoriteLimits();
|
||||||
|
const searching = Boolean(filter);
|
||||||
|
|
||||||
|
const [editFolder, setEditFolder] = useState<FavoriteFolder | null>(null);
|
||||||
|
const [favoriteMenu, setFavoriteMenu] = useState<{ world: World; folder: string } | null>(null);
|
||||||
|
const [removeWorld, setRemoveWorld] = useState<World | null>(null);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
world: World;
|
||||||
|
folder: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [selecting, setSelecting] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void api.world.loadFavorites();
|
||||||
|
}, [selfId]);
|
||||||
|
|
||||||
|
if (!folders.length) return <SkeletonGrid count={3} />;
|
||||||
|
|
||||||
|
const filtered = filter
|
||||||
|
? folders.map((f) => ({ ...f, worlds: f.worlds.filter(filter) }))
|
||||||
|
: folders;
|
||||||
|
const shown = searching ? filtered.filter((f) => f.worlds.length) : filtered;
|
||||||
|
|
||||||
|
if (!shown.length) return <p className="text-[13px] text-faint">{t("profile:worlds.empty")}</p>;
|
||||||
|
|
||||||
|
const hasDeleted = shown.some((f) => f.worlds.some((w) => w.deleted));
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setSelected((s) => {
|
||||||
|
const next = new Set(s);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectOne = (id: string) => {
|
||||||
|
setSelected(new Set([id]));
|
||||||
|
setSelecting(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectWhere = (match: (world: World) => boolean) => {
|
||||||
|
setSelected(
|
||||||
|
new Set(shown.flatMap((folder) => folder.worlds.filter(match).map((world) => world.id))),
|
||||||
|
);
|
||||||
|
setSelecting(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exitSelect = () => {
|
||||||
|
setSelecting(false);
|
||||||
|
setSelected(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFavorite = async () => {
|
||||||
|
if (!removeWorld) return;
|
||||||
|
await api.world.unfavorite(removeWorld.id);
|
||||||
|
setRemoveWorld(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems = (world: World, folder: string): ContextMenuEntry[] => [
|
||||||
|
{ label: t("world:context.open"), icon: <Eye size={14} />, onClick: () => nav.openWorld(world.id) },
|
||||||
|
{
|
||||||
|
label: t("world:context.select"),
|
||||||
|
icon: <CheckSquare size={14} />,
|
||||||
|
onClick: () => selectOne(world.id),
|
||||||
|
},
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: t("world:favorite.manage"),
|
||||||
|
icon: <Star size={14} />,
|
||||||
|
onClick: () => setFavoriteMenu({ world, folder }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("world:favorite.unfavorite"),
|
||||||
|
icon: <Trash2 size={14} />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => setRemoveWorld(world),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{selecting ? (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => selectWhere((w) => w.releaseStatus === "private")}>
|
||||||
|
{t("world:bulk.selectPrivate")}
|
||||||
|
</Button>
|
||||||
|
{hasDeleted ? (
|
||||||
|
<Button variant="ghost" onClick={() => selectWhere((w) => Boolean(w.deleted))}>
|
||||||
|
{t("world:bulk.selectDeleted")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Button variant="ghost" onClick={() => (selecting ? exitSelect() : setSelecting(true))}>
|
||||||
|
<CheckSquare size={15} />
|
||||||
|
{selecting ? t("world:bulk.done") : t("world:bulk.select")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shown.map((folder) => (
|
||||||
|
<CollapsibleCard
|
||||||
|
key={folder.name}
|
||||||
|
title={folder.displayName}
|
||||||
|
count={`${folder.count} / ${maxPerGroup}`}
|
||||||
|
action={
|
||||||
|
<IconButton
|
||||||
|
title={t("world:folder.edit")}
|
||||||
|
onClick={() => setEditFolder(folder)}
|
||||||
|
aria-label={t("world:folder.edit")}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{folder.worlds.length ? (
|
||||||
|
<CardGrid>
|
||||||
|
{folder.worlds.map((w) => (
|
||||||
|
<WorldCard
|
||||||
|
key={w.id}
|
||||||
|
world={w}
|
||||||
|
showAuthor
|
||||||
|
selectable={selecting}
|
||||||
|
selected={selected.has(w.id)}
|
||||||
|
onToggleSelect={() => toggle(w.id)}
|
||||||
|
onContextMenu={
|
||||||
|
selecting
|
||||||
|
? undefined
|
||||||
|
: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, world: w, folder: folder.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardGrid>
|
||||||
|
) : (
|
||||||
|
<p className="text-[13px] text-faint">{t("world:folder.empty")}</p>
|
||||||
|
)}
|
||||||
|
</CollapsibleCard>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{editFolder ? (
|
||||||
|
<WorldFolderEditModal folder={editFolder} onClose={() => setEditFolder(null)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{favoriteMenu ? (
|
||||||
|
<WorldFavoriteModal
|
||||||
|
world={favoriteMenu.world}
|
||||||
|
currentFolder={favoriteMenu.folder}
|
||||||
|
onClose={() => setFavoriteMenu(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{removeWorld ? (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={() => setRemoveWorld(null)}
|
||||||
|
title={t("world:bulk.unfavoriteTitle")}
|
||||||
|
icon={<Trash2 size={16} />}
|
||||||
|
danger
|
||||||
|
confirmLabel={t("world:favorite.unfavorite")}
|
||||||
|
onConfirm={removeFavorite}
|
||||||
|
>
|
||||||
|
<p className="text-[13px] text-muted">
|
||||||
|
{t("world:context.unfavoriteConfirm", { name: removeWorld.name })}
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{contextMenu ? (
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
items={menuItems(contextMenu.world, contextMenu.folder)}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selecting && selected.size > 0 ? (
|
||||||
|
<WorldBulkBar ids={[...selected]} onDone={exitSelect} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorldBulkBar({ ids, onDone }: { ids: string[]; onDone: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [moveOpen, setMoveOpen] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
const unfavorite = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.world.unfavoriteMany(ids);
|
||||||
|
onDone();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectionBar label={t("world:bulk.selected", { count: ids.length })}>
|
||||||
|
<SelectionBarButton onClick={onDone} disabled={busy}>
|
||||||
|
<X size={15} /> {t("world:bulk.done")}
|
||||||
|
</SelectionBarButton>
|
||||||
|
<SelectionBarButton onClick={() => setMoveOpen(true)} disabled={busy}>
|
||||||
|
<FolderInput size={15} /> {t("world:bulk.move")}
|
||||||
|
</SelectionBarButton>
|
||||||
|
<SelectionBarButton danger onClick={() => setConfirmDelete(true)} disabled={busy}>
|
||||||
|
<Trash2 size={15} /> {t("world:favorite.unfavorite")}
|
||||||
|
</SelectionBarButton>
|
||||||
|
{confirmDelete ? (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={() => {
|
||||||
|
if (!busy) setConfirmDelete(false);
|
||||||
|
}}
|
||||||
|
title={t("world:bulk.unfavoriteTitle")}
|
||||||
|
icon={<Trash2 size={16} />}
|
||||||
|
danger
|
||||||
|
confirmLabel={t("world:favorite.unfavorite")}
|
||||||
|
confirmLoading={busy}
|
||||||
|
onConfirm={unfavorite}
|
||||||
|
>
|
||||||
|
<p className="text-[13px] text-muted">
|
||||||
|
{t("world:bulk.unfavoriteConfirm", { count: ids.length })}
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
{moveOpen ? (
|
||||||
|
<WorldBulkMoveModal
|
||||||
|
ids={ids}
|
||||||
|
onClose={() => setMoveOpen(false)}
|
||||||
|
onMoved={() => {
|
||||||
|
setMoveOpen(false);
|
||||||
|
onDone();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SelectionBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { FolderInput, Star } from "lucide-react";
|
||||||
|
import { Modal } from "../../components/ui";
|
||||||
|
import { api, errorMessage } from "../../lib/api";
|
||||||
|
import { useT } from "../../lib/i18n";
|
||||||
|
import { useWorldFolderSlots } from "../../store/worldFavorites";
|
||||||
|
|
||||||
|
export function WorldBulkMoveModal({
|
||||||
|
ids,
|
||||||
|
onClose,
|
||||||
|
onMoved,
|
||||||
|
}: {
|
||||||
|
ids: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
onMoved: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const slots = useWorldFolderSlots();
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(0);
|
||||||
|
const [skipped, setSkipped] = useState(0);
|
||||||
|
|
||||||
|
const move = async (folder: string) => {
|
||||||
|
setBusy(folder);
|
||||||
|
setError(null);
|
||||||
|
setDone(0);
|
||||||
|
setSkipped(0);
|
||||||
|
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;
|
||||||
|
setSkipped(skippedCount);
|
||||||
|
setDone((n) => n + 1);
|
||||||
|
}
|
||||||
|
if (!skippedCount) {
|
||||||
|
onMoved();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err, t("world:favorite.failed")));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = ids.length ? Math.round((done / ids.length) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("world:bulk.moveTitle", { count: ids.length })}
|
||||||
|
icon={<FolderInput size={16} />}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5 text-left">
|
||||||
|
{busy ? (
|
||||||
|
<div className="mb-2 flex flex-col gap-1.5">
|
||||||
|
<span className="text-[12px] text-muted">
|
||||||
|
{t("world:bulk.movingProgress", { done, total: ids.length })}
|
||||||
|
</span>
|
||||||
|
{skipped ? (
|
||||||
|
<p className="text-[12px] font-medium text-danger">
|
||||||
|
{t("world:bulk.skipped", { count: skipped })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="h-1 overflow-hidden rounded-full bg-surface-hover">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-accent transition-[width] duration-200 ease-fluid"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<button
|
||||||
|
key={slot.name}
|
||||||
|
onClick={() => move(slot.name)}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-left transition-colors hover:not-disabled:border-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="text-faint">
|
||||||
|
<Star size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[13px] font-medium">{slot.displayName}</span>
|
||||||
|
<span className="text-[11px] tabular-nums text-faint">
|
||||||
|
{t("world:favorite.folderCount", { count: slot.count })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!busy && skipped ? (
|
||||||
|
<p className="text-[12px] font-medium text-danger">
|
||||||
|
{t("world:bulk.skipped", { count: skipped })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Circle, Star, Users } from "lucide-react";
|
import type { MouseEvent } from "react";
|
||||||
|
import { Check, Circle, Star, Users } from "lucide-react";
|
||||||
import type { World } from "../../../../shared/types/world";
|
import type { World } from "../../../../shared/types/world";
|
||||||
import { Card, HoverImage, IconLabel, Tag } from "../../components/ui";
|
import { Card, HoverImage, IconLabel, Tag } from "../../components/ui";
|
||||||
import { compactNumber } from "../../lib/format";
|
import { compactNumber } from "../../lib/format";
|
||||||
@@ -9,27 +10,63 @@ export function WorldCard({
|
|||||||
world,
|
world,
|
||||||
showAuthor,
|
showAuthor,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
selectable,
|
||||||
|
selected,
|
||||||
|
onToggleSelect,
|
||||||
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
world: World;
|
world: World;
|
||||||
showAuthor?: boolean;
|
showAuthor?: boolean;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
|
selectable?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
onToggleSelect?: () => void;
|
||||||
|
onContextMenu?: (e: MouseEvent) => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { openWorld } = useNav();
|
const { openWorld } = useNav();
|
||||||
const img = world.thumbnailImageUrl || world.imageUrl;
|
const img = world.thumbnailImageUrl || world.imageUrl;
|
||||||
return (
|
return (
|
||||||
<Card onClick={onOpen ?? (() => openWorld(world.id))}>
|
<Card
|
||||||
<div className="relative aspect-video bg-surface-hover">
|
onClick={
|
||||||
{img ? <HoverImage src={img} loading="lazy" /> : null}
|
selectable ? onToggleSelect : world.deleted ? undefined : (onOpen ?? (() => openWorld(world.id)))
|
||||||
{world.releaseStatus !== "public" ? (
|
}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
className={`${selected ? "outline outline-[3px] -outline-offset-[3px] outline-accent" : ""} ${
|
||||||
|
world.deleted && !selectable ? "opacity-70" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video overflow-hidden bg-surface-hover">
|
||||||
|
<div
|
||||||
|
className={`h-full w-full transition-transform duration-200 ease-fluid ${selected ? "scale-90" : ""}`}
|
||||||
|
>
|
||||||
|
{img ? <HoverImage src={img} loading="lazy" /> : null}
|
||||||
|
</div>
|
||||||
|
{selectable ? (
|
||||||
|
<span
|
||||||
|
className={`absolute left-1.5 top-1.5 flex size-5 items-center justify-center rounded-md border transition-colors ${
|
||||||
|
selected ? "border-accent bg-accent text-on-accent" : "border-border bg-surface-2/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected ? <Check size={13} /> : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{world.deleted ? (
|
||||||
<span className="absolute right-1.5 top-1.5">
|
<span className="absolute right-1.5 top-1.5">
|
||||||
<Tag color="var(--status-ask)">{world.releaseStatus}</Tag>
|
<Tag color="var(--status-busy)">{t("world:deleted.badge")}</Tag>
|
||||||
|
</span>
|
||||||
|
) : world.releaseStatus !== "public" ? (
|
||||||
|
<span className="absolute right-1.5 top-1.5">
|
||||||
|
<Tag color="var(--status-ask)">{t(`world:releaseStatus.${world.releaseStatus}`)}</Tag>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5">
|
<div className="p-2.5">
|
||||||
<div className="truncate text-[13px] font-semibold" title={world.name}>
|
<div
|
||||||
{world.name}
|
className={`truncate text-[13px] font-semibold ${world.deleted ? "text-faint italic" : ""}`}
|
||||||
|
title={world.name || (world.deleted ? t("world:deleted.name") : "")}
|
||||||
|
>
|
||||||
|
{world.name || (world.deleted ? t("world:deleted.name") : "")}
|
||||||
</div>
|
</div>
|
||||||
{showAuthor ? (
|
{showAuthor ? (
|
||||||
<div className="truncate text-[11px] text-faint" title={world.authorName}>
|
<div className="truncate text-[11px] text-faint" title={world.authorName}>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Check, Star } from "lucide-react";
|
||||||
|
import type { World } from "../../../../shared/types/world";
|
||||||
|
import { Button, Modal } from "../../components/ui";
|
||||||
|
import { api, errorMessage } from "../../lib/api";
|
||||||
|
import { useT } from "../../lib/i18n";
|
||||||
|
import { useWorldFolderSlots } from "../../store/worldFavorites";
|
||||||
|
|
||||||
|
export function WorldFavoriteModal({
|
||||||
|
world,
|
||||||
|
currentFolder,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
world: World;
|
||||||
|
currentFolder?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const slots = useWorldFolderSlots();
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [skipped, setSkipped] = useState(false);
|
||||||
|
|
||||||
|
const run = async (key: string, fn: () => Promise<void>) => {
|
||||||
|
setBusy(key);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err, t("world:favorite.failed")));
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (folder: string) => {
|
||||||
|
if (folder === currentFolder) return onClose();
|
||||||
|
if (currentFolder) {
|
||||||
|
setBusy(folder);
|
||||||
|
setError(null);
|
||||||
|
setSkipped(false);
|
||||||
|
return api.world
|
||||||
|
.moveFavorite(world.id, folder)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.skipped.length) {
|
||||||
|
setSkipped(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorMessage(err, t("world:favorite.failed"))))
|
||||||
|
.finally(() => setBusy(null));
|
||||||
|
}
|
||||||
|
return run(folder, () => api.world.favorite(world.id, folder));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
title={currentFolder ? t("world:favorite.manage") : t("world:favorite.add")}
|
||||||
|
icon={<Star size={16} />}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5 text-left">
|
||||||
|
{skipped ? (
|
||||||
|
<p className="mb-1 text-[12px] font-medium text-danger">
|
||||||
|
{t("world:bulk.skipped", { count: 1 })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{busy ? (
|
||||||
|
<div className="mb-2 flex flex-col gap-1.5">
|
||||||
|
<span className="text-[12px] text-muted">{t("world:bulk.moving")}</span>
|
||||||
|
<div className="h-1 overflow-hidden rounded-full bg-surface-hover">
|
||||||
|
<div className="h-full w-full rounded-full bg-accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{slots.map((slot) => {
|
||||||
|
const isCurrent = slot.name === currentFolder;
|
||||||
|
const disabled = busy !== null || (slot.full && !isCurrent);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={slot.name}
|
||||||
|
onClick={() => pick(slot.name)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-left transition-colors hover:not-disabled:border-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="text-faint">
|
||||||
|
<Star size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[13px] font-medium">{slot.displayName}</span>
|
||||||
|
<span className="text-[11px] tabular-nums text-faint">
|
||||||
|
{t("world:favorite.folderCount", { count: slot.count })}
|
||||||
|
{slot.full ? ` · ${t("world:favorite.folderFull")}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{isCurrent ? <Check size={15} className="text-accent" /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{currentFolder ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="mt-1 justify-center"
|
||||||
|
block
|
||||||
|
loading={busy === "unfavorite"}
|
||||||
|
onClick={() => run("unfavorite", () => api.world.unfavorite(world.id))}
|
||||||
|
>
|
||||||
|
{t("world:favorite.unfavorite")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import type { FavoriteVisibility } from "../../../../shared/types/world";
|
||||||
|
import { Button, Field, INPUT_CLASS, Modal } from "../../components/ui";
|
||||||
|
import { api, errorMessage } from "../../lib/api";
|
||||||
|
import { useT } from "../../lib/i18n";
|
||||||
|
import type { FavoriteFolder } from "../../store/worldFavorites";
|
||||||
|
|
||||||
|
const VISIBILITIES: FavoriteVisibility[] = ["private", "friends", "public"];
|
||||||
|
|
||||||
|
export function WorldFolderEditModal({
|
||||||
|
folder,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
folder: FavoriteFolder;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const [displayName, setDisplayName] = useState(folder.displayName);
|
||||||
|
const [visibility, setVisibility] = useState<FavoriteVisibility>(folder.visibility);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.world.updateFavoriteFolder(folder.name, { displayName, visibility });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err, t("world:favorite.failed")));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.world.clearFavoriteFolder(folder.name);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err, t("world:favorite.failed")));
|
||||||
|
setBusy(false);
|
||||||
|
setConfirmClear(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("world:folder.editTitle")}
|
||||||
|
icon={<Pencil size={16} />}
|
||||||
|
confirmLabel={t("world:favorite.save")}
|
||||||
|
onConfirm={save}
|
||||||
|
confirmLoading={busy}
|
||||||
|
confirmDisabled={!displayName.trim()}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 text-left">
|
||||||
|
<Field
|
||||||
|
label={t("world:folder.name")}
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-muted">{t("world:folder.visibility")}</span>
|
||||||
|
<select
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => setVisibility(e.target.value as FavoriteVisibility)}
|
||||||
|
>
|
||||||
|
{VISIBILITIES.map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{t(`world:visibility.${v}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{folder.count > 0 ? (
|
||||||
|
<div className="mt-1 border-t border-border pt-3">
|
||||||
|
{confirmClear ? (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-[12px] text-muted">
|
||||||
|
{t("world:folder.clearConfirm", { count: folder.count })}
|
||||||
|
</span>
|
||||||
|
<Button variant="danger" onClick={clear} loading={busy}>
|
||||||
|
{t("world:folder.clear")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmClear(true)}
|
||||||
|
className="flex items-center gap-1.5 text-[12px] font-medium text-danger transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} /> {t("world:folder.clear")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,37 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Globe, Heart, Plus, Tag as TagIcon, Users } from "lucide-react";
|
import { Globe, Heart, Plus, Star, Tag as TagIcon, Users } from "lucide-react";
|
||||||
import type { World } from "../../../../shared/types/world";
|
import type { World } from "../../../../shared/types/world";
|
||||||
import { Banner, Button, Fact, Section, Skeleton, StatTile, Tag } from "../../components/ui";
|
import { Banner, Button, Fact, Section, Skeleton, StatTile, Tag } from "../../components/ui";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { useAsync } from "../../lib/useAsync";
|
import { useAsync } from "../../lib/useAsync";
|
||||||
import { compactNumber, formatDate, prettyTag, tagsWithPrefix } from "../../lib/format";
|
import { compactNumber, formatDate, prettyTag, tagsWithPrefix } from "../../lib/format";
|
||||||
import { useWorlds } from "../../store/worlds";
|
import { useWorlds } from "../../store/worlds";
|
||||||
|
import { useWorldFolder } from "../../store/worldFavorites";
|
||||||
import { useNav } from "../navigation/NavContext";
|
import { useNav } from "../navigation/NavContext";
|
||||||
import { useT } from "../../lib/i18n";
|
import { useT } from "../../lib/i18n";
|
||||||
import { COL_WIDE } from "../../lib/layout";
|
import { COL_WIDE } from "../../lib/layout";
|
||||||
import { HeroHeader } from "../shared/HeroHeader";
|
import { HeroHeader } from "../shared/HeroHeader";
|
||||||
import { CreateInstanceModal } from "./CreateInstanceModal";
|
import { CreateInstanceModal } from "./CreateInstanceModal";
|
||||||
|
import { WorldFavoriteModal } from "./WorldFavoriteModal";
|
||||||
import "../profile/profile.css";
|
import "../profile/profile.css";
|
||||||
|
|
||||||
export function WorldView({ worldId }: { worldId: string }) {
|
export function WorldView({ worldId }: { worldId: string }) {
|
||||||
const cached = useWorlds((s) => s.worlds[worldId]);
|
const cached = useWorlds((s) => s.worlds[worldId]);
|
||||||
const load = useAsync(() => api.world.get(worldId), [worldId], "This world is unavailable.");
|
const load = useAsync(() => api.world.get(worldId), [worldId], "This world is unavailable.");
|
||||||
|
|
||||||
if (load.status === "error" && !cached?.detailed) {
|
if (cached?.detailed) return <WorldCard world={cached} />;
|
||||||
|
if (load.status === "error") {
|
||||||
return <Banner className="m-10 max-w-[420px]">{load.message}</Banner>;
|
return <Banner className="m-10 max-w-[420px]">{load.message}</Banner>;
|
||||||
}
|
}
|
||||||
if (!cached?.detailed) return <WorldSkeleton />;
|
return <WorldSkeleton />;
|
||||||
return <WorldCard world={cached} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorldCard({ world }: { world: World }) {
|
function WorldCard({ world }: { world: World }) {
|
||||||
const { openUser } = useNav();
|
const { openUser } = useNav();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [favoriteOpen, setFavoriteOpen] = useState(false);
|
||||||
|
const folder = useWorldFolder(world.id);
|
||||||
const banner = world.imageUrl || world.thumbnailImageUrl;
|
const banner = world.imageUrl || world.thumbnailImageUrl;
|
||||||
const players = world.occupants;
|
const players = world.occupants;
|
||||||
|
|
||||||
@@ -55,7 +59,7 @@ function WorldCard({ world }: { world: World }) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||||
{world.releaseStatus !== "public" ? (
|
{world.releaseStatus !== "public" ? (
|
||||||
<Tag color="var(--status-ask)">{world.releaseStatus}</Tag>
|
<Tag color="var(--status-ask)">{t(`world:releaseStatus.${world.releaseStatus}`)}</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
{world.platforms?.pc ? <Tag>PC</Tag> : null}
|
{world.platforms?.pc ? <Tag>PC</Tag> : null}
|
||||||
{world.platforms?.android ? <Tag color="var(--status-join)">Quest</Tag> : null}
|
{world.platforms?.android ? <Tag color="var(--status-join)">Quest</Tag> : null}
|
||||||
@@ -63,13 +67,26 @@ function WorldCard({ world }: { world: World }) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<Button className="mb-1 ml-auto shrink-0" onClick={() => setCreateOpen(true)}>
|
<div className="mb-1 ml-auto flex shrink-0 items-center gap-2">
|
||||||
<Plus size={15} />
|
<Button
|
||||||
{t("world:createInstance")}
|
variant={folder ? "primary" : "ghost"}
|
||||||
</Button>
|
onClick={() => setFavoriteOpen(true)}
|
||||||
|
title={folder ? t("world:favorite.manage") : t("world:favorite.add")}
|
||||||
|
>
|
||||||
|
<Star size={15} fill={folder ? "currentColor" : "none"} />
|
||||||
|
{folder ? t("world:favorite.favorited") : t("world:favorite.add")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus size={15} />
|
||||||
|
{t("world:createInstance")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CreateInstanceModal worldId={world.id} open={createOpen} onClose={() => setCreateOpen(false)} />
|
<CreateInstanceModal worldId={world.id} open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||||
|
{favoriteOpen ? (
|
||||||
|
<WorldFavoriteModal world={world} currentFolder={folder} onClose={() => setFavoriteOpen(false)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<StatTile
|
<StatTile
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import type { EnhancementId } from "../../../shared/types/enhancements";
|
|||||||
import type { CreateInstanceInput } from "../../../shared/types/instance";
|
import type { CreateInstanceInput } from "../../../shared/types/instance";
|
||||||
import type { PreferredRegion } from "../../../shared/types/appConfig";
|
import type { PreferredRegion } from "../../../shared/types/appConfig";
|
||||||
import type { AvatarEdit, FavoriteGroupEdit, MoveResult } from "../../../shared/types/avatar";
|
import type { AvatarEdit, FavoriteGroupEdit, MoveResult } from "../../../shared/types/avatar";
|
||||||
|
import type {
|
||||||
|
FavoriteGroupEdit as WorldFavoriteGroupEdit,
|
||||||
|
MoveResult as WorldMoveResult,
|
||||||
|
} from "../../../shared/types/world";
|
||||||
|
|
||||||
export class ApiException extends Error {
|
export class ApiException extends Error {
|
||||||
constructor(public readonly error: ApiError) {
|
constructor(public readonly error: ApiError) {
|
||||||
@@ -63,6 +67,18 @@ export const api = {
|
|||||||
favorites: (userId: string) => call("world:favorites", userId),
|
favorites: (userId: string) => call("world:favorites", userId),
|
||||||
get: (worldId: string) => call("world:get", worldId),
|
get: (worldId: string) => call("world:get", worldId),
|
||||||
snapshot: () => call("world:snapshot"),
|
snapshot: () => call("world:snapshot"),
|
||||||
|
favoritesSnapshot: () => call("world:favoritesSnapshot"),
|
||||||
|
loadFavorites: () => call("world:loadFavorites"),
|
||||||
|
favorite: (worldId: string, folder?: string) => call("world:favorite", { worldId, folder }),
|
||||||
|
unfavorite: (worldId: string) => call("world:unfavorite", worldId),
|
||||||
|
moveFavorite: (worldId: string, folder: string): Promise<WorldMoveResult> =>
|
||||||
|
call("world:moveFavorite", { worldId, folder }),
|
||||||
|
unfavoriteMany: (worldIds: string[]) => call("world:unfavoriteMany", worldIds),
|
||||||
|
moveFavoriteMany: (worldIds: string[], folder: string): Promise<WorldMoveResult> =>
|
||||||
|
call("world:moveFavoriteMany", { worldIds, folder }),
|
||||||
|
clearFavoriteFolder: (folder: string) => call("world:clearFavoriteFolder", folder),
|
||||||
|
updateFavoriteFolder: (folder: string, edit: WorldFavoriteGroupEdit) =>
|
||||||
|
call("world:updateFavoriteFolder", { folder, edit }),
|
||||||
},
|
},
|
||||||
instance: {
|
instance: {
|
||||||
get: (worldId: string, instanceId: string) => call("instance:get", { worldId, instanceId }),
|
get: (worldId: string, instanceId: string) => call("instance:get", { worldId, instanceId }),
|
||||||
|
|||||||
@@ -1,5 +1,57 @@
|
|||||||
{
|
{
|
||||||
"createInstance": "Create Instance",
|
"createInstance": "Create Instance",
|
||||||
|
"deleted": {
|
||||||
|
"badge": "Deleted",
|
||||||
|
"name": "Deleted world"
|
||||||
|
},
|
||||||
|
"releaseStatus": {
|
||||||
|
"public": "Public",
|
||||||
|
"private": "Private",
|
||||||
|
"hidden": "Hidden"
|
||||||
|
},
|
||||||
|
"favorite": {
|
||||||
|
"add": "Favorite",
|
||||||
|
"favorited": "Favorited",
|
||||||
|
"manage": "Manage favorite",
|
||||||
|
"unfavorite": "Unfavorite",
|
||||||
|
"save": "Save",
|
||||||
|
"failed": "Action failed.",
|
||||||
|
"folderCount": "{{count}} worlds",
|
||||||
|
"folderFull": "Full"
|
||||||
|
},
|
||||||
|
"folder": {
|
||||||
|
"edit": "Edit folder",
|
||||||
|
"editTitle": "Edit folder",
|
||||||
|
"name": "Folder name",
|
||||||
|
"visibility": "Visibility",
|
||||||
|
"clear": "Empty folder",
|
||||||
|
"clearConfirm": "Remove all {{count}} worlds?",
|
||||||
|
"empty": "This folder is empty."
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"private": "Private",
|
||||||
|
"friends": "Friends",
|
||||||
|
"public": "Public"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open": "Open world",
|
||||||
|
"select": "Select world",
|
||||||
|
"unfavoriteConfirm": "Remove \"{{name}}\" from your favorites?"
|
||||||
|
},
|
||||||
|
"bulk": {
|
||||||
|
"select": "Select",
|
||||||
|
"done": "Done",
|
||||||
|
"selectPrivate": "Select private",
|
||||||
|
"selectDeleted": "Select deleted",
|
||||||
|
"selected": "{{count}} selected",
|
||||||
|
"move": "Move",
|
||||||
|
"moving": "Moving favorites...",
|
||||||
|
"movingProgress": "Moving {{done}} / {{total}} favorites...",
|
||||||
|
"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."
|
||||||
|
},
|
||||||
"regions": {
|
"regions": {
|
||||||
"us": "US West",
|
"us": "US West",
|
||||||
"use": "US East",
|
"use": "US East",
|
||||||
|
|||||||
@@ -1,5 +1,57 @@
|
|||||||
{
|
{
|
||||||
"createInstance": "インスタンスを作成",
|
"createInstance": "インスタンスを作成",
|
||||||
|
"deleted": {
|
||||||
|
"badge": "削除済み",
|
||||||
|
"name": "削除されたワールド"
|
||||||
|
},
|
||||||
|
"releaseStatus": {
|
||||||
|
"public": "公開",
|
||||||
|
"private": "非公開",
|
||||||
|
"hidden": "非表示"
|
||||||
|
},
|
||||||
|
"favorite": {
|
||||||
|
"add": "お気に入り登録",
|
||||||
|
"favorited": "お気に入り済み",
|
||||||
|
"manage": "お気に入りを管理",
|
||||||
|
"unfavorite": "お気に入り解除",
|
||||||
|
"save": "保存",
|
||||||
|
"failed": "操作に失敗しました。",
|
||||||
|
"folderCount": "{{count}} 件",
|
||||||
|
"folderFull": "満杯"
|
||||||
|
},
|
||||||
|
"folder": {
|
||||||
|
"edit": "フォルダを編集",
|
||||||
|
"editTitle": "フォルダを編集",
|
||||||
|
"name": "フォルダ名",
|
||||||
|
"visibility": "公開設定",
|
||||||
|
"clear": "フォルダを空にする",
|
||||||
|
"clearConfirm": "{{count}} 件すべて削除しますか?",
|
||||||
|
"empty": "このフォルダは空です。"
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"private": "非公開",
|
||||||
|
"friends": "フレンド",
|
||||||
|
"public": "公開"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open": "ワールドを開く",
|
||||||
|
"select": "ワールドを選択",
|
||||||
|
"unfavoriteConfirm": "「{{name}}」をお気に入りから削除しますか?"
|
||||||
|
},
|
||||||
|
"bulk": {
|
||||||
|
"select": "選択",
|
||||||
|
"done": "完了",
|
||||||
|
"selectPrivate": "非公開を選択",
|
||||||
|
"selectDeleted": "削除済みを選択",
|
||||||
|
"selected": "{{count}} 件選択中",
|
||||||
|
"move": "移動",
|
||||||
|
"moving": "お気に入りを移動中...",
|
||||||
|
"movingProgress": "{{done}} / {{total}} 件を移動中...",
|
||||||
|
"unfavoriteTitle": "お気に入りから削除",
|
||||||
|
"unfavoriteConfirm": "選択した {{count}} 件をお気に入りから削除しますか?",
|
||||||
|
"moveTitle": "{{count}} 件を移動",
|
||||||
|
"skipped": "{{count}} 件は移動できず(非公開または削除済み)、そのままになりました。"
|
||||||
|
},
|
||||||
"regions": {
|
"regions": {
|
||||||
"us": "米国西部",
|
"us": "米国西部",
|
||||||
"use": "米国東部",
|
"use": "米国東部",
|
||||||
|
|||||||
@@ -1,5 +1,57 @@
|
|||||||
{
|
{
|
||||||
"createInstance": "สร้างอินสแตนซ์",
|
"createInstance": "สร้างอินสแตนซ์",
|
||||||
|
"deleted": {
|
||||||
|
"badge": "ถูกลบ",
|
||||||
|
"name": "เวิลด์ที่ถูกลบ"
|
||||||
|
},
|
||||||
|
"releaseStatus": {
|
||||||
|
"public": "สาธารณะ",
|
||||||
|
"private": "ส่วนตัว",
|
||||||
|
"hidden": "ซ่อนไว้"
|
||||||
|
},
|
||||||
|
"favorite": {
|
||||||
|
"add": "เพิ่มรายการโปรด",
|
||||||
|
"favorited": "อยู่ในรายการโปรด",
|
||||||
|
"manage": "จัดการรายการโปรด",
|
||||||
|
"unfavorite": "เอาออกจากรายการโปรด",
|
||||||
|
"save": "บันทึก",
|
||||||
|
"failed": "การดำเนินการล้มเหลว",
|
||||||
|
"folderCount": "{{count}} รายการ",
|
||||||
|
"folderFull": "เต็ม"
|
||||||
|
},
|
||||||
|
"folder": {
|
||||||
|
"edit": "แก้ไขโฟลเดอร์",
|
||||||
|
"editTitle": "แก้ไขโฟลเดอร์",
|
||||||
|
"name": "ชื่อโฟลเดอร์",
|
||||||
|
"visibility": "การมองเห็น",
|
||||||
|
"clear": "ล้างโฟลเดอร์",
|
||||||
|
"clearConfirm": "ลบทั้งหมด {{count}} รายการหรือไม่?",
|
||||||
|
"empty": "โฟลเดอร์นี้ว่างเปล่า"
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"private": "ส่วนตัว",
|
||||||
|
"friends": "เพื่อน",
|
||||||
|
"public": "สาธารณะ"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"open": "เปิดเวิลด์",
|
||||||
|
"select": "เลือกเวิลด์",
|
||||||
|
"unfavoriteConfirm": "ลบ \"{{name}}\" ออกจากรายการโปรดหรือไม่?"
|
||||||
|
},
|
||||||
|
"bulk": {
|
||||||
|
"select": "เลือก",
|
||||||
|
"done": "เสร็จ",
|
||||||
|
"selectPrivate": "เลือกส่วนตัว",
|
||||||
|
"selectDeleted": "เลือกที่ถูกลบ",
|
||||||
|
"selected": "เลือก {{count}} รายการ",
|
||||||
|
"move": "ย้าย",
|
||||||
|
"moving": "กำลังย้ายรายการโปรด...",
|
||||||
|
"movingProgress": "กำลังย้าย {{done}} / {{total}} รายการ...",
|
||||||
|
"unfavoriteTitle": "ลบจากรายการโปรด",
|
||||||
|
"unfavoriteConfirm": "ลบเวิลด์ที่เลือก {{count}} รายการออกจากรายการโปรดหรือไม่?",
|
||||||
|
"moveTitle": "ย้าย {{count}} รายการ",
|
||||||
|
"skipped": "ย้ายไม่ได้ {{count}} รายการ (ส่วนตัวหรือถูกลบแล้ว) และยังอยู่ที่เดิม"
|
||||||
|
},
|
||||||
"regions": {
|
"regions": {
|
||||||
"us": "สหรัฐฯ ฝั่งตะวันตก",
|
"us": "สหรัฐฯ ฝั่งตะวันตก",
|
||||||
"use": "สหรัฐฯ ฝั่งตะวันออก",
|
"use": "สหรัฐฯ ฝั่งตะวันออก",
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import type {
|
||||||
|
FavoriteLimits,
|
||||||
|
FavoriteWorldGroup,
|
||||||
|
World,
|
||||||
|
WorldFavoritesSnapshot,
|
||||||
|
} from "../../../shared/types/world";
|
||||||
|
import { api, events } from "../lib/api";
|
||||||
|
import { useWorlds } from "./worlds";
|
||||||
|
|
||||||
|
const DEFAULT_LIMITS: FavoriteLimits = { maxGroups: 4, maxPerGroup: 64 };
|
||||||
|
|
||||||
|
interface WorldFavoritesState {
|
||||||
|
groups: FavoriteWorldGroup[];
|
||||||
|
limits: FavoriteLimits;
|
||||||
|
seed: (s: WorldFavoritesSnapshot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorldFavorites = create<WorldFavoritesState>((set) => ({
|
||||||
|
groups: [],
|
||||||
|
limits: DEFAULT_LIMITS,
|
||||||
|
seed: (s) => set({ groups: s.groups, limits: s.limits }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
events.on("world:favorites:seed", (s) => useWorldFavorites.getState().seed(s));
|
||||||
|
api.world
|
||||||
|
.favoritesSnapshot()
|
||||||
|
.then((s) => useWorldFavorites.getState().seed(s))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
export interface FavoriteFolder {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
visibility: FavoriteWorldGroup["visibility"];
|
||||||
|
count: number;
|
||||||
|
full: boolean;
|
||||||
|
worlds: World[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFavoriteWorldFolders(): FavoriteFolder[] {
|
||||||
|
const groups = useWorldFavorites((s) => s.groups);
|
||||||
|
const worlds = useWorlds((s) => s.worlds);
|
||||||
|
const maxPerGroup = useWorldFavorites((s) => s.limits.maxPerGroup);
|
||||||
|
return useMemo(
|
||||||
|
() => groups.map((g) => toFolder(g, worlds, maxPerGroup)),
|
||||||
|
[groups, worlds, maxPerGroup],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFolder(
|
||||||
|
g: FavoriteWorldGroup,
|
||||||
|
worlds: Record<string, World>,
|
||||||
|
maxPerGroup: number,
|
||||||
|
): FavoriteFolder {
|
||||||
|
return {
|
||||||
|
name: g.name,
|
||||||
|
displayName: g.displayName,
|
||||||
|
visibility: g.visibility,
|
||||||
|
count: g.worldIds.length,
|
||||||
|
full: g.worldIds.length >= maxPerGroup,
|
||||||
|
worlds: g.worldIds.map((id) => worlds[id]).filter((w): w is World => Boolean(w)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorldFavoriteLimits = (): FavoriteLimits =>
|
||||||
|
useWorldFavorites((s) => s.limits);
|
||||||
|
|
||||||
|
export function useWorldFolder(worldId: string): string | undefined {
|
||||||
|
return useWorldFavorites((s) => s.groups.find((g) => g.worldIds.includes(worldId))?.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolderSlot {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
count: number;
|
||||||
|
full: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorldFolderSlots(): FolderSlot[] {
|
||||||
|
const groups = useWorldFavorites((s) => s.groups);
|
||||||
|
const maxPerGroup = useWorldFavorites((s) => s.limits.maxPerGroup);
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
groups.map((g) => ({
|
||||||
|
name: g.name,
|
||||||
|
displayName: g.displayName,
|
||||||
|
count: g.worldIds.length,
|
||||||
|
full: g.worldIds.length >= maxPerGroup,
|
||||||
|
})),
|
||||||
|
[groups, maxPerGroup],
|
||||||
|
);
|
||||||
|
}
|
||||||
+25
-1
@@ -6,7 +6,15 @@ import type {
|
|||||||
TwoFactorPayload,
|
TwoFactorPayload,
|
||||||
} from "./types/auth";
|
} from "./types/auth";
|
||||||
import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user";
|
import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user";
|
||||||
import type { DiscoverCategory, FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
|
import type {
|
||||||
|
DiscoverCategory,
|
||||||
|
FavoriteWorldFolder,
|
||||||
|
World,
|
||||||
|
WorldSnapshot,
|
||||||
|
WorldFavoritesSnapshot,
|
||||||
|
FavoriteGroupEdit as WorldFavoriteGroupEdit,
|
||||||
|
MoveResult as WorldMoveResult,
|
||||||
|
} from "./types/world";
|
||||||
import type { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance";
|
import type { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance";
|
||||||
import type { UnityStatus } from "./types/unity";
|
import type { UnityStatus } from "./types/unity";
|
||||||
import type {
|
import type {
|
||||||
@@ -58,6 +66,21 @@ export interface IpcRequests {
|
|||||||
"world:favorites": (userId: string) => IpcResult<FavoriteWorldFolder[]>;
|
"world:favorites": (userId: string) => IpcResult<FavoriteWorldFolder[]>;
|
||||||
"world:get": (worldId: string) => IpcResult<World>;
|
"world:get": (worldId: string) => IpcResult<World>;
|
||||||
"world:snapshot": () => IpcResult<WorldSnapshot>;
|
"world:snapshot": () => IpcResult<WorldSnapshot>;
|
||||||
|
"world:favoritesSnapshot": () => IpcResult<WorldFavoritesSnapshot>;
|
||||||
|
"world:loadFavorites": () => IpcResult<void>;
|
||||||
|
"world:favorite": (p: { worldId: string; folder?: string }) => IpcResult<void>;
|
||||||
|
"world:unfavorite": (worldId: string) => IpcResult<void>;
|
||||||
|
"world:moveFavorite": (p: { worldId: string; folder: string }) => IpcResult<WorldMoveResult>;
|
||||||
|
"world:unfavoriteMany": (worldIds: string[]) => IpcResult<void>;
|
||||||
|
"world:moveFavoriteMany": (p: {
|
||||||
|
worldIds: string[];
|
||||||
|
folder: string;
|
||||||
|
}) => IpcResult<WorldMoveResult>;
|
||||||
|
"world:clearFavoriteFolder": (folder: string) => IpcResult<void>;
|
||||||
|
"world:updateFavoriteFolder": (p: {
|
||||||
|
folder: string;
|
||||||
|
edit: WorldFavoriteGroupEdit;
|
||||||
|
}) => IpcResult<void>;
|
||||||
|
|
||||||
"instance:get": (location: { worldId: string; instanceId: string }) => IpcResult<Instance>;
|
"instance:get": (location: { worldId: string; instanceId: string }) => IpcResult<Instance>;
|
||||||
"instance:create": (input: CreateInstanceInput) => IpcResult<Instance>;
|
"instance:create": (input: CreateInstanceInput) => IpcResult<Instance>;
|
||||||
@@ -163,6 +186,7 @@ export interface IpcEvents {
|
|||||||
"avatar:seed": AvatarSnapshot;
|
"avatar:seed": AvatarSnapshot;
|
||||||
"avatar:upsert": Avatar;
|
"avatar:upsert": Avatar;
|
||||||
"world:favoriteFolders": { userId: string; folders: FavoriteWorldFolder[]; done: boolean };
|
"world:favoriteFolders": { userId: string; folders: FavoriteWorldFolder[]; done: boolean };
|
||||||
|
"world:favorites:seed": WorldFavoritesSnapshot;
|
||||||
"game:changed": GameStatus;
|
"game:changed": GameStatus;
|
||||||
"instance:open": { worldId: string; instanceId: string; location: string };
|
"instance:open": { worldId: string; instanceId: string; location: string };
|
||||||
"gallery:added": Photo;
|
"gallery:added": Photo;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface World {
|
|||||||
platforms?: WorldPlatforms;
|
platforms?: WorldPlatforms;
|
||||||
publicOccupants?: number;
|
publicOccupants?: number;
|
||||||
privateOccupants?: number;
|
privateOccupants?: number;
|
||||||
|
deleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorldSnapshot {
|
export interface WorldSnapshot {
|
||||||
@@ -46,6 +47,35 @@ export interface FavoriteWorldFolder {
|
|||||||
worldIds: string[];
|
worldIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FavoriteVisibility = "private" | "friends" | "public";
|
||||||
|
|
||||||
|
export interface FavoriteWorldGroup {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
visibility: FavoriteVisibility;
|
||||||
|
worldIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FavoriteLimits {
|
||||||
|
maxGroups: number;
|
||||||
|
maxPerGroup: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FavoriteGroupEdit {
|
||||||
|
displayName?: string;
|
||||||
|
visibility?: FavoriteVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveResult {
|
||||||
|
moved: number;
|
||||||
|
skipped: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldFavoritesSnapshot {
|
||||||
|
groups: FavoriteWorldGroup[];
|
||||||
|
limits: FavoriteLimits;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiscoverCategory {
|
export interface DiscoverCategory {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user