feat: Manage world favorites

This commit is contained in:
2026-06-30 05:40:47 +07:00
parent 6c048e8a28
commit 82fcc5eca9
21 changed files with 1324 additions and 31 deletions
+1
View File
@@ -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",
+13
View File
@@ -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)),
+2
View File
@@ -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) &&
+5
View File
@@ -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));
} }
+59
View File
@@ -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();
+5 -2
View File
@@ -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 ?? "",
+230 -7
View File
@@ -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>
);
}
+45 -8
View File
@@ -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>
);
}
+26 -9
View File
@@ -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
+16
View File
@@ -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": "สหรัฐฯ ฝั่งตะวันออก",
+93
View File
@@ -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
View File
@@ -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;
+30
View File
@@ -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;