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",
|
||||
userWorlds: (id: string) => `user:worlds:${id}`,
|
||||
favoriteWorlds: (id: string) => `worlds:favorites:${id}`,
|
||||
myFavoriteWorlds: () => "worlds:favorites:mine",
|
||||
world: (id: string) => `world:${id}`,
|
||||
avatar: (id: string) => `avatar:${id}`,
|
||||
avatarFavorites: () => "avatar:favorites",
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as game from "../game/launch";
|
||||
import * as region from "../game/region";
|
||||
import { socialSnapshot } from "../store/social";
|
||||
import { worldStore } from "../store/worldStore";
|
||||
import { worldFavoritesStore } from "../store/worldFavoritesStore";
|
||||
import { groupStore } from "../store/groupStore";
|
||||
import { openDebugWindow } from "../windows";
|
||||
|
||||
@@ -53,6 +54,18 @@ const handlers = {
|
||||
"world:discover": () => guard(() => worlds.getDiscover()),
|
||||
"world:get": (worldId) => guard(() => worlds.getWorld(worldId)),
|
||||
"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 }) =>
|
||||
guard(() => instances.getInstance(worldId, instanceId)),
|
||||
|
||||
@@ -132,6 +132,8 @@ export class Repository<T extends Entity> {
|
||||
const prev = fields[key];
|
||||
const cls = this.policy.classOf(key);
|
||||
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 (
|
||||
this.policy.keepNonEmpty?.has(key) &&
|
||||
isEmpty(incoming) &&
|
||||
|
||||
@@ -10,6 +10,7 @@ import { entityStore, type SocialSnapshot } from "./entityStore";
|
||||
import { worldStore } from "./worldStore";
|
||||
import { groupStore } from "./groupStore";
|
||||
import { avatarStore } from "./avatarStore";
|
||||
import { worldFavoritesStore } from "./worldFavoritesStore";
|
||||
import { repos } from "./repository/manager";
|
||||
import { broadcast } from "../windows";
|
||||
import { logger } from "../debug/logger";
|
||||
@@ -172,6 +173,7 @@ export async function seedActiveAccount(force = false): Promise<void> {
|
||||
repos.setActive(id);
|
||||
if (!id) {
|
||||
worldStore.reset();
|
||||
worldFavoritesStore.reset();
|
||||
groupStore.reset();
|
||||
avatarStore.reset();
|
||||
entityStore.reset();
|
||||
@@ -180,6 +182,7 @@ export async function seedActiveAccount(force = false): Promise<void> {
|
||||
if (!force && alreadyActive) return;
|
||||
|
||||
worldStore.reset();
|
||||
worldFavoritesStore.reset();
|
||||
groupStore.reset();
|
||||
entityStore.reset();
|
||||
|
||||
@@ -219,4 +222,6 @@ export function startSocialBridge(): void {
|
||||
if (c.type === "seed") broadcast("avatar:seed", c.snapshot);
|
||||
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;
|
||||
|
||||
// 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 {
|
||||
const platforms = raw.unityPackages ? platformsOf(raw.unityPackages) : undefined;
|
||||
const detailed = "visits" in raw;
|
||||
return {
|
||||
id: raw.id,
|
||||
detailed,
|
||||
name: raw.name,
|
||||
name: unhide(raw.name),
|
||||
authorId: raw.authorId ?? "",
|
||||
authorName: raw.authorName,
|
||||
authorName: unhide(raw.authorName),
|
||||
description: "description" in raw ? (raw.description ?? "") : "",
|
||||
imageUrl: raw.imageUrl ?? "",
|
||||
thumbnailImageUrl: raw.thumbnailImageUrl ?? "",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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 {
|
||||
getCategoryWorlds,
|
||||
@@ -9,17 +17,26 @@ import {
|
||||
} from "./rawEndpoints";
|
||||
import { toWorld } from "./mappers";
|
||||
import { cachedRead } from "./cachedRead";
|
||||
import { requireActiveClient } from "./client";
|
||||
import { userCache, currentUser } from "./userService";
|
||||
import { worldStore } from "../store/worldStore";
|
||||
import { worldFavoritesStore, type FavoriteGroupInput } from "../store/worldFavoritesStore";
|
||||
import { broadcast } from "../windows";
|
||||
import { cacheKeys, policies } from "../cache/policies";
|
||||
|
||||
export async function getWorld(worldId: string): Promise<World> {
|
||||
const world = await cachedRead(cacheKeys.world(worldId), policies.world, async (vrc) => {
|
||||
const { data } = await vrc.getWorld({ path: { worldId }, throwOnError: true });
|
||||
return toWorld(data);
|
||||
});
|
||||
worldStore.addWorld(world);
|
||||
return world;
|
||||
try {
|
||||
const world = await cachedRead(cacheKeys.world(worldId), policies.world, async (vrc) => {
|
||||
const { data } = await vrc.getWorld({ path: { worldId }, throwOnError: true });
|
||||
return toWorld(data);
|
||||
});
|
||||
worldStore.addWorld(world);
|
||||
return world;
|
||||
} catch (err) {
|
||||
const fallback = worldStore.get(worldId);
|
||||
if (fallback) return fallback;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
type CachedFavorites = { worlds: World[]; folders: FavoriteWorldFolder[] };
|
||||
@@ -156,6 +173,212 @@ async function loadCategory(
|
||||
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[]> {
|
||||
const worlds = await cachedRead(
|
||||
cacheKeys.userWorlds(userId),
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Banner, Loader, PAGE_TITLE, Tabs } from "../../components/ui";
|
||||
import { useT } from "../../lib/i18n";
|
||||
import { useViewState } from "../navigation/NavContext";
|
||||
import { useProfile } from "./useProfile";
|
||||
import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection";
|
||||
import { WorldsSection, WorldSearch } from "./WorldsSection";
|
||||
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";
|
||||
|
||||
@@ -49,9 +50,7 @@ export function MyWorldsView() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="rise-in">
|
||||
<WorldSearch>
|
||||
{(filter) => <FavoriteWorldsSection userId={userId} filter={filter} />}
|
||||
</WorldSearch>
|
||||
<WorldSearch>{(filter) => <MyFavoriteWorldsSection filter={filter} />}</WorldSearch>
|
||||
</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 { Card, HoverImage, IconLabel, Tag } from "../../components/ui";
|
||||
import { compactNumber } from "../../lib/format";
|
||||
@@ -9,27 +10,63 @@ export function WorldCard({
|
||||
world,
|
||||
showAuthor,
|
||||
onOpen,
|
||||
selectable,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
onContextMenu,
|
||||
}: {
|
||||
world: World;
|
||||
showAuthor?: boolean;
|
||||
onOpen?: () => void;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
onContextMenu?: (e: MouseEvent) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const { openWorld } = useNav();
|
||||
const img = world.thumbnailImageUrl || world.imageUrl;
|
||||
return (
|
||||
<Card onClick={onOpen ?? (() => openWorld(world.id))}>
|
||||
<div className="relative aspect-video bg-surface-hover">
|
||||
{img ? <HoverImage src={img} loading="lazy" /> : null}
|
||||
{world.releaseStatus !== "public" ? (
|
||||
<Card
|
||||
onClick={
|
||||
selectable ? onToggleSelect : world.deleted ? undefined : (onOpen ?? (() => openWorld(world.id)))
|
||||
}
|
||||
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">
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-2.5">
|
||||
<div className="truncate text-[13px] font-semibold" title={world.name}>
|
||||
{world.name}
|
||||
<div
|
||||
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>
|
||||
{showAuthor ? (
|
||||
<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 { 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 { Banner, Button, Fact, Section, Skeleton, StatTile, Tag } from "../../components/ui";
|
||||
import { api } from "../../lib/api";
|
||||
import { useAsync } from "../../lib/useAsync";
|
||||
import { compactNumber, formatDate, prettyTag, tagsWithPrefix } from "../../lib/format";
|
||||
import { useWorlds } from "../../store/worlds";
|
||||
import { useWorldFolder } from "../../store/worldFavorites";
|
||||
import { useNav } from "../navigation/NavContext";
|
||||
import { useT } from "../../lib/i18n";
|
||||
import { COL_WIDE } from "../../lib/layout";
|
||||
import { HeroHeader } from "../shared/HeroHeader";
|
||||
import { CreateInstanceModal } from "./CreateInstanceModal";
|
||||
import { WorldFavoriteModal } from "./WorldFavoriteModal";
|
||||
import "../profile/profile.css";
|
||||
|
||||
export function WorldView({ worldId }: { worldId: string }) {
|
||||
const cached = useWorlds((s) => s.worlds[worldId]);
|
||||
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>;
|
||||
}
|
||||
if (!cached?.detailed) return <WorldSkeleton />;
|
||||
return <WorldCard world={cached} />;
|
||||
return <WorldSkeleton />;
|
||||
}
|
||||
|
||||
function WorldCard({ world }: { world: World }) {
|
||||
const { openUser } = useNav();
|
||||
const t = useT();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [favoriteOpen, setFavoriteOpen] = useState(false);
|
||||
const folder = useWorldFolder(world.id);
|
||||
const banner = world.imageUrl || world.thumbnailImageUrl;
|
||||
const players = world.occupants;
|
||||
|
||||
@@ -55,7 +59,7 @@ function WorldCard({ world }: { world: World }) {
|
||||
</p>
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
{world.releaseStatus !== "public" ? (
|
||||
<Tag color="var(--status-ask)">{world.releaseStatus}</Tag>
|
||||
<Tag color="var(--status-ask)">{t(`world:releaseStatus.${world.releaseStatus}`)}</Tag>
|
||||
) : null}
|
||||
{world.platforms?.pc ? <Tag>PC</Tag> : null}
|
||||
{world.platforms?.android ? <Tag color="var(--status-join)">Quest</Tag> : null}
|
||||
@@ -63,13 +67,26 @@ function WorldCard({ world }: { world: World }) {
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<Button className="mb-1 ml-auto shrink-0" onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={15} />
|
||||
{t("world:createInstance")}
|
||||
</Button>
|
||||
<div className="mb-1 ml-auto flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant={folder ? "primary" : "ghost"}
|
||||
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)} />
|
||||
{favoriteOpen ? (
|
||||
<WorldFavoriteModal world={world} currentFolder={folder} onClose={() => setFavoriteOpen(false)} />
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatTile
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { EnhancementId } from "../../../shared/types/enhancements";
|
||||
import type { CreateInstanceInput } from "../../../shared/types/instance";
|
||||
import type { PreferredRegion } from "../../../shared/types/appConfig";
|
||||
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 {
|
||||
constructor(public readonly error: ApiError) {
|
||||
@@ -63,6 +67,18 @@ export const api = {
|
||||
favorites: (userId: string) => call("world:favorites", userId),
|
||||
get: (worldId: string) => call("world:get", worldId),
|
||||
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: {
|
||||
get: (worldId: string, instanceId: string) => call("instance:get", { worldId, instanceId }),
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
{
|
||||
"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": {
|
||||
"us": "US West",
|
||||
"use": "US East",
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
{
|
||||
"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": {
|
||||
"us": "米国西部",
|
||||
"use": "米国東部",
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
{
|
||||
"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": {
|
||||
"us": "สหรัฐฯ ฝั่งตะวันตก",
|
||||
"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,
|
||||
} from "./types/auth";
|
||||
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 { UnityStatus } from "./types/unity";
|
||||
import type {
|
||||
@@ -58,6 +66,21 @@ export interface IpcRequests {
|
||||
"world:favorites": (userId: string) => IpcResult<FavoriteWorldFolder[]>;
|
||||
"world:get": (worldId: string) => IpcResult<World>;
|
||||
"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:create": (input: CreateInstanceInput) => IpcResult<Instance>;
|
||||
@@ -163,6 +186,7 @@ export interface IpcEvents {
|
||||
"avatar:seed": AvatarSnapshot;
|
||||
"avatar:upsert": Avatar;
|
||||
"world:favoriteFolders": { userId: string; folders: FavoriteWorldFolder[]; done: boolean };
|
||||
"world:favorites:seed": WorldFavoritesSnapshot;
|
||||
"game:changed": GameStatus;
|
||||
"instance:open": { worldId: string; instanceId: string; location: string };
|
||||
"gallery:added": Photo;
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface World {
|
||||
platforms?: WorldPlatforms;
|
||||
publicOccupants?: number;
|
||||
privateOccupants?: number;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
export interface WorldSnapshot {
|
||||
@@ -46,6 +47,35 @@ export interface FavoriteWorldFolder {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user