From 82fcc5eca9dc5f44225486e154306adb04a09ff6 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Tue, 30 Jun 2026 05:40:09 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Manage=20world=20favorites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/cache/policies.ts | 1 + src/main/ipc/handlers.ts | 13 + src/main/store/repository/repository.ts | 2 + src/main/store/social.ts | 5 + src/main/store/worldFavoritesStore.ts | 59 ++++ src/main/vrchat/mappers.ts | 7 +- src/main/vrchat/worldService.ts | 237 ++++++++++++++- .../src/features/profile/MyWorldsView.tsx | 7 +- .../world/MyFavoriteWorldsSection.tsx | 286 ++++++++++++++++++ .../src/features/world/WorldBulkMoveModal.tsx | 102 +++++++ src/renderer/src/features/world/WorldCard.tsx | 53 +++- .../src/features/world/WorldFavoriteModal.tsx | 119 ++++++++ .../features/world/WorldFolderEditModal.tsx | 108 +++++++ src/renderer/src/features/world/WorldView.tsx | 35 ++- src/renderer/src/lib/api.ts | 16 + .../src/lib/i18n/locales/en/world.json | 52 ++++ .../src/lib/i18n/locales/ja/world.json | 52 ++++ .../src/lib/i18n/locales/th/world.json | 52 ++++ src/renderer/src/store/worldFavorites.ts | 93 ++++++ src/shared/ipc.ts | 26 +- src/shared/types/world.ts | 30 ++ 21 files changed, 1324 insertions(+), 31 deletions(-) create mode 100644 src/main/store/worldFavoritesStore.ts create mode 100644 src/renderer/src/features/world/MyFavoriteWorldsSection.tsx create mode 100644 src/renderer/src/features/world/WorldBulkMoveModal.tsx create mode 100644 src/renderer/src/features/world/WorldFavoriteModal.tsx create mode 100644 src/renderer/src/features/world/WorldFolderEditModal.tsx create mode 100644 src/renderer/src/store/worldFavorites.ts diff --git a/src/main/cache/policies.ts b/src/main/cache/policies.ts index d450df7..c16e48e 100644 --- a/src/main/cache/policies.ts +++ b/src/main/cache/policies.ts @@ -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", diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d521aca..ce7451e 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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)), diff --git a/src/main/store/repository/repository.ts b/src/main/store/repository/repository.ts index 1a729a2..2880f8c 100644 --- a/src/main/store/repository/repository.ts +++ b/src/main/store/repository/repository.ts @@ -132,6 +132,8 @@ export class Repository { 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) && diff --git a/src/main/store/social.ts b/src/main/store/social.ts index ea86fb4..330e376 100644 --- a/src/main/store/social.ts +++ b/src/main/store/social.ts @@ -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 { 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 { 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)); } diff --git a/src/main/store/worldFavoritesStore.ts b/src/main/store/worldFavoritesStore.ts new file mode 100644 index 0000000..9b866d7 --- /dev/null +++ b/src/main/store/worldFavoritesStore.ts @@ -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(); + 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(); diff --git a/src/main/vrchat/mappers.ts b/src/main/vrchat/mappers.ts index 68ff537..75a24c1 100644 --- a/src/main/vrchat/mappers.ts +++ b/src/main/vrchat/mappers.ts @@ -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 ?? "", diff --git a/src/main/vrchat/worldService.ts b/src/main/vrchat/worldService.ts index 3544cf4..730efa3 100644 --- a/src/main/vrchat/worldService.ts +++ b/src/main/vrchat/worldService.ts @@ -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 { - 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 { + 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 { + const vrc = requireActiveClient(); + const seen = new Set(); + 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 { + 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 { + const vrc = requireActiveClient(); + await vrc.addFavorite({ + body: { type: "world", favoriteId: worldId, tags: [folder] }, + throwOnError: true, + }); + await reloadMyFavorites(); +} + +export async function unfavoriteWorld(worldId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { const worlds = await cachedRead( cacheKeys.userWorlds(userId), diff --git a/src/renderer/src/features/profile/MyWorldsView.tsx b/src/renderer/src/features/profile/MyWorldsView.tsx index 99d047d..783ed7f 100644 --- a/src/renderer/src/features/profile/MyWorldsView.tsx +++ b/src/renderer/src/features/profile/MyWorldsView.tsx @@ -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() { ) : (
- - {(filter) => } - + {(filter) => }
)} diff --git a/src/renderer/src/features/world/MyFavoriteWorldsSection.tsx b/src/renderer/src/features/world/MyFavoriteWorldsSection.tsx new file mode 100644 index 0000000..f825038 --- /dev/null +++ b/src/renderer/src/features/world/MyFavoriteWorldsSection.tsx @@ -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(null); + const [favoriteMenu, setFavoriteMenu] = useState<{ world: World; folder: string } | null>(null); + const [removeWorld, setRemoveWorld] = useState(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + world: World; + folder: string; + } | null>(null); + const [selecting, setSelecting] = useState(false); + const [selected, setSelected] = useState>(() => new Set()); + + useEffect(() => { + void api.world.loadFavorites(); + }, [selfId]); + + if (!folders.length) return ; + + 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

{t("profile:worlds.empty")}

; + + 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: , onClick: () => nav.openWorld(world.id) }, + { + label: t("world:context.select"), + icon: , + onClick: () => selectOne(world.id), + }, + { separator: true }, + { + label: t("world:favorite.manage"), + icon: , + onClick: () => setFavoriteMenu({ world, folder }), + }, + { + label: t("world:favorite.unfavorite"), + icon: , + danger: true, + onClick: () => setRemoveWorld(world), + }, + ]; + + return ( +
+
+
+ {selecting ? ( + <> + + {hasDeleted ? ( + + ) : null} + + ) : null} + +
+
+ + {shown.map((folder) => ( + setEditFolder(folder)} + aria-label={t("world:folder.edit")} + > + + + } + > + {folder.worlds.length ? ( + + {folder.worlds.map((w) => ( + toggle(w.id)} + onContextMenu={ + selecting + ? undefined + : (e) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, world: w, folder: folder.name }); + } + } + /> + ))} + + ) : ( +

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

+ )} +
+ ))} + + {editFolder ? ( + setEditFolder(null)} /> + ) : null} + + {favoriteMenu ? ( + setFavoriteMenu(null)} + /> + ) : null} + + {removeWorld ? ( + setRemoveWorld(null)} + title={t("world:bulk.unfavoriteTitle")} + icon={} + danger + confirmLabel={t("world:favorite.unfavorite")} + onConfirm={removeFavorite} + > +

+ {t("world:context.unfavoriteConfirm", { name: removeWorld.name })} +

+
+ ) : null} + + {contextMenu ? ( + setContextMenu(null)} + /> + ) : null} + + {selecting && selected.size > 0 ? ( + + ) : null} +
+ ); +} + +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 ( + + + {t("world:bulk.done")} + + setMoveOpen(true)} disabled={busy}> + {t("world:bulk.move")} + + setConfirmDelete(true)} disabled={busy}> + {t("world:favorite.unfavorite")} + + {confirmDelete ? ( + { + if (!busy) setConfirmDelete(false); + }} + title={t("world:bulk.unfavoriteTitle")} + icon={} + danger + confirmLabel={t("world:favorite.unfavorite")} + confirmLoading={busy} + onConfirm={unfavorite} + > +

+ {t("world:bulk.unfavoriteConfirm", { count: ids.length })} +

+
+ ) : null} + {moveOpen ? ( + setMoveOpen(false)} + onMoved={() => { + setMoveOpen(false); + onDone(); + }} + /> + ) : null} +
+ ); +} diff --git a/src/renderer/src/features/world/WorldBulkMoveModal.tsx b/src/renderer/src/features/world/WorldBulkMoveModal.tsx new file mode 100644 index 0000000..9974132 --- /dev/null +++ b/src/renderer/src/features/world/WorldBulkMoveModal.tsx @@ -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(null); + const [error, setError] = useState(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 ( + } + > +
+ {busy ? ( +
+ + {t("world:bulk.movingProgress", { done, total: ids.length })} + + {skipped ? ( +

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

+ ) : null} +
+
+
+
+ ) : null} + {slots.map((slot) => ( + + ))} + {!busy && skipped ? ( +

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

+ ) : null} + {error ?

{error}

: null} +
+ + ); +} diff --git a/src/renderer/src/features/world/WorldCard.tsx b/src/renderer/src/features/world/WorldCard.tsx index dcd0e1c..5416b35 100644 --- a/src/renderer/src/features/world/WorldCard.tsx +++ b/src/renderer/src/features/world/WorldCard.tsx @@ -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 ( - openWorld(world.id))}> -
- {img ? : null} - {world.releaseStatus !== "public" ? ( + openWorld(world.id))) + } + onContextMenu={onContextMenu} + className={`${selected ? "outline outline-[3px] -outline-offset-[3px] outline-accent" : ""} ${ + world.deleted && !selectable ? "opacity-70" : "" + }`} + > +
+
+ {img ? : null} +
+ {selectable ? ( + + {selected ? : null} + + ) : null} + {world.deleted ? ( - {world.releaseStatus} + {t("world:deleted.badge")} + + ) : world.releaseStatus !== "public" ? ( + + {t(`world:releaseStatus.${world.releaseStatus}`)} ) : null}
-
- {world.name} +
+ {world.name || (world.deleted ? t("world:deleted.name") : "")}
{showAuthor ? (
diff --git a/src/renderer/src/features/world/WorldFavoriteModal.tsx b/src/renderer/src/features/world/WorldFavoriteModal.tsx new file mode 100644 index 0000000..362079b --- /dev/null +++ b/src/renderer/src/features/world/WorldFavoriteModal.tsx @@ -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(null); + const [error, setError] = useState(null); + const [skipped, setSkipped] = useState(false); + + const run = async (key: string, fn: () => Promise) => { + 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 ( + } + > +
+ {skipped ? ( +

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

+ ) : null} + {busy ? ( +
+ {t("world:bulk.moving")} +
+
+
+
+ ) : null} + {slots.map((slot) => { + const isCurrent = slot.name === currentFolder; + const disabled = busy !== null || (slot.full && !isCurrent); + return ( + + ); + })} + + {currentFolder ? ( + + ) : null} + + {error ?

{error}

: null} +
+ + ); +} diff --git a/src/renderer/src/features/world/WorldFolderEditModal.tsx b/src/renderer/src/features/world/WorldFolderEditModal.tsx new file mode 100644 index 0000000..f202780 --- /dev/null +++ b/src/renderer/src/features/world/WorldFolderEditModal.tsx @@ -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(folder.visibility); + const [busy, setBusy] = useState(false); + const [confirmClear, setConfirmClear] = useState(false); + const [error, setError] = useState(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 ( + } + confirmLabel={t("world:favorite.save")} + onConfirm={save} + confirmLoading={busy} + confirmDisabled={!displayName.trim()} + > +
+ setDisplayName(e.target.value)} + /> + + + {folder.count > 0 ? ( +
+ {confirmClear ? ( +
+ + {t("world:folder.clearConfirm", { count: folder.count })} + + +
+ ) : ( + + )} +
+ ) : null} + + {error ?

{error}

: null} +
+
+ ); +} diff --git a/src/renderer/src/features/world/WorldView.tsx b/src/renderer/src/features/world/WorldView.tsx index fe4c340..c043a4b 100644 --- a/src/renderer/src/features/world/WorldView.tsx +++ b/src/renderer/src/features/world/WorldView.tsx @@ -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 ; + if (load.status === "error") { return {load.message}; } - if (!cached?.detailed) return ; - return ; + return ; } 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 }) {

{world.releaseStatus !== "public" ? ( - {world.releaseStatus} + {t(`world:releaseStatus.${world.releaseStatus}`)} ) : null} {world.platforms?.pc ? PC : null} {world.platforms?.android ? Quest : null} @@ -63,13 +67,26 @@ function WorldCard({ world }: { world: World }) {
} actions={ - +
+ + +
} > setCreateOpen(false)} /> + {favoriteOpen ? ( + setFavoriteOpen(false)} /> + ) : null}
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 => + call("world:moveFavorite", { worldId, folder }), + unfavoriteMany: (worldIds: string[]) => call("world:unfavoriteMany", worldIds), + moveFavoriteMany: (worldIds: string[], folder: string): Promise => + 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 }), diff --git a/src/renderer/src/lib/i18n/locales/en/world.json b/src/renderer/src/lib/i18n/locales/en/world.json index 9c3d50d..f6f3f86 100644 --- a/src/renderer/src/lib/i18n/locales/en/world.json +++ b/src/renderer/src/lib/i18n/locales/en/world.json @@ -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", diff --git a/src/renderer/src/lib/i18n/locales/ja/world.json b/src/renderer/src/lib/i18n/locales/ja/world.json index 62e49a4..940d9fb 100644 --- a/src/renderer/src/lib/i18n/locales/ja/world.json +++ b/src/renderer/src/lib/i18n/locales/ja/world.json @@ -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": "米国東部", diff --git a/src/renderer/src/lib/i18n/locales/th/world.json b/src/renderer/src/lib/i18n/locales/th/world.json index 57fcc72..a5be720 100644 --- a/src/renderer/src/lib/i18n/locales/th/world.json +++ b/src/renderer/src/lib/i18n/locales/th/world.json @@ -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": "สหรัฐฯ ฝั่งตะวันออก", diff --git a/src/renderer/src/store/worldFavorites.ts b/src/renderer/src/store/worldFavorites.ts new file mode 100644 index 0000000..e003983 --- /dev/null +++ b/src/renderer/src/store/worldFavorites.ts @@ -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((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, + 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], + ); +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 444fb8a..a453308 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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; "world:get": (worldId: string) => IpcResult; "world:snapshot": () => IpcResult; + "world:favoritesSnapshot": () => IpcResult; + "world:loadFavorites": () => IpcResult; + "world:favorite": (p: { worldId: string; folder?: string }) => IpcResult; + "world:unfavorite": (worldId: string) => IpcResult; + "world:moveFavorite": (p: { worldId: string; folder: string }) => IpcResult; + "world:unfavoriteMany": (worldIds: string[]) => IpcResult; + "world:moveFavoriteMany": (p: { + worldIds: string[]; + folder: string; + }) => IpcResult; + "world:clearFavoriteFolder": (folder: string) => IpcResult; + "world:updateFavoriteFolder": (p: { + folder: string; + edit: WorldFavoriteGroupEdit; + }) => IpcResult; "instance:get": (location: { worldId: string; instanceId: string }) => IpcResult; "instance:create": (input: CreateInstanceInput) => IpcResult; @@ -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; diff --git a/src/shared/types/world.ts b/src/shared/types/world.ts index 6df5d02..52279a3 100644 --- a/src/shared/types/world.ts +++ b/src/shared/types/world.ts @@ -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;