diff --git a/src/main/cache/policies.ts b/src/main/cache/policies.ts index 631446b..3f9af5e 100644 --- a/src/main/cache/policies.ts +++ b/src/main/cache/policies.ts @@ -5,6 +5,7 @@ export const policies = { user: { ttl: 5 * 60_000, staleWhileRevalidate: 10 * 60_000 }, userSearch: { ttl: 5 * 60_000 }, worldSearch: { ttl: 5 * 60_000 }, + discover: { ttl: 10 * 60_000, staleWhileRevalidate: 30 * 60_000 }, friends: { ttl: 5 * 60_000, staleWhileRevalidate: 60_000 }, userWorlds: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, favoriteWorlds: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, @@ -25,6 +26,7 @@ export const cacheKeys = { userByName: (name: string) => `user:name:${name.toLowerCase()}`, userSearch: (q: string) => `user:search:${q.trim().toLowerCase()}`, worldSearch: (q: string) => `world:search:${q.trim().toLowerCase()}`, + discover: () => "world:discover", friends: () => "friends", userWorlds: (id: string) => `user:worlds:${id}`, favoriteWorlds: (id: string) => `worlds:favorites:${id}`, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 5ffff97..4233b16 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -50,6 +50,7 @@ const handlers = { }), "world:favorites": (userId) => guard(() => worlds.getFavoriteWorlds(userId)), "world:search": (query) => guard(() => worlds.searchWorlds(query)), + "world:discover": () => guard(() => worlds.getDiscover()), "world:get": (worldId) => guard(() => worlds.getWorld(worldId)), "world:snapshot": () => guard(async () => worldStore.snapshot()), diff --git a/src/main/vrchat/rawEndpoints.ts b/src/main/vrchat/rawEndpoints.ts index 8cafb2d..09b0734 100644 --- a/src/main/vrchat/rawEndpoints.ts +++ b/src/main/vrchat/rawEndpoints.ts @@ -1,4 +1,4 @@ -import type { VRChat, FavoritedWorld } from "vrchat"; +import type { VRChat, FavoritedWorld, LimitedWorld } from "vrchat"; // VRChat web routes that are missing from the SDK. @@ -37,3 +37,90 @@ export async function getFavoriteGroupWorlds( } return worlds; } + +interface InfoPushContentList { + name: string | { fallback?: string }; + shortName?: string | null; + tag?: string | null; + sortHeading?: string; + sortOrder?: string; + sortOwnership?: string; + platform?: string; +} + +interface InfoPushEntry { + id: string; + priority?: number; + startDate?: string; + endDate?: string; + data?: { contentList?: InfoPushContentList }; +} + +export interface WorldCategory { + id: string; + name: string; + tag?: string; + sort?: string; + order?: string; + platform?: string; +} + +export async function getWorldCategories(vrc: VRChat): Promise { + const { data } = await vrc.client.get({ + url: "/infoPush", + query: { include: "user-all", require: "world-category" }, + throwOnError: true, + }); + + // vrchat gives events the lowest priority number, but we want them first + return data + .filter((e) => e.data?.contentList) + .sort((a, b) => { + const ev = Number(isEvent(b)) - Number(isEvent(a)); + if (ev !== 0) return ev; + return (a.priority ?? 0) - (b.priority ?? 0); + }) + .map((e) => { + const cl = e.data!.contentList!; + return { + id: e.id, + name: categoryName(cl), + tag: cl.tag || undefined, + sort: cl.sortHeading, + order: cl.sortOrder, + platform: cl.platform === "ThisPlatformSupported" ? undefined : cl.platform, + }; + }); +} + +function isEvent(e: InfoPushEntry): boolean { + const tag = e.data?.contentList?.tag ?? ""; + return tag.startsWith("admin_") || Boolean(e.startDate) || Boolean(e.endDate); +} + +function categoryName(cl: InfoPushContentList): string { + if (typeof cl.name === "string") return cl.name; + return cl.name.fallback ?? cl.shortName ?? "Worlds"; +} + +export async function getCategoryWorlds( + vrc: VRChat, + category: WorldCategory, + n: number, +): Promise { + const tags = ["system_approved", category.tag].filter(Boolean).join(","); + const { data } = await vrc.client.get({ + url: "/worlds", + query: { + releaseStatus: "public", + sort: category.sort, + order: category.order ?? "descending", + tag: tags || undefined, + featured: false, + n, + offset: 0, + }, + throwOnError: true, + }); + return data; +} diff --git a/src/main/vrchat/worldService.ts b/src/main/vrchat/worldService.ts index 37e12ee..3544cf4 100644 --- a/src/main/vrchat/worldService.ts +++ b/src/main/vrchat/worldService.ts @@ -1,7 +1,12 @@ import type { VRChat } from "vrchat"; -import type { FavoriteWorldFolder, World } from "../../shared/types/world"; +import type { DiscoverCategory, FavoriteWorldFolder, World } from "../../shared/types/world"; import { httpStatusOf } from "./errors"; -import { getFavoriteGroupWorlds, type WorldFavoriteGroupType } from "./rawEndpoints"; +import { + getCategoryWorlds, + getFavoriteGroupWorlds, + getWorldCategories, + type WorldFavoriteGroupType, +} from "./rawEndpoints"; import { toWorld } from "./mappers"; import { cachedRead } from "./cachedRead"; import { worldStore } from "../store/worldStore"; @@ -131,6 +136,26 @@ export async function searchWorlds(query: string): Promise { return worlds; } +const DISCOVER_ROW_SIZE = 12; + +export async function getDiscover(): Promise { + const categories = await cachedRead(cacheKeys.discover(), policies.discover, async (vrc) => { + const cats = await getWorldCategories(vrc); + const rows = await Promise.all(cats.map((cat) => loadCategory(vrc, cat).catch(() => null))); + return rows.filter((r): r is DiscoverCategory => r !== null && r.worlds.length > 0); + }); + for (const cat of categories) for (const w of cat.worlds) worldStore.addWorld(w); + return categories; +} + +async function loadCategory( + vrc: VRChat, + cat: Awaited>[number], +): Promise { + const raw = await getCategoryWorlds(vrc, cat, DISCOVER_ROW_SIZE); + return { id: cat.id, name: cat.name, worlds: raw.map(toWorld) }; +} + export async function getUserWorlds(userId: string, isSelf: boolean): Promise { const worlds = await cachedRead( cacheKeys.userWorlds(userId), diff --git a/src/renderer/src/components/AppShell.tsx b/src/renderer/src/components/AppShell.tsx index 941084c..add1edc 100644 --- a/src/renderer/src/components/AppShell.tsx +++ b/src/renderer/src/components/AppShell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { ArrowLeft, ChevronLeft, @@ -6,6 +6,7 @@ import { CircleDot, ExternalLink, Images, + Globe2, Search, Settings, SlidersHorizontal, @@ -14,6 +15,7 @@ import { } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { ProfileView } from "../features/profile/ProfileView"; +import { MyWorldsView } from "../features/profile/MyWorldsView"; import { WorldView } from "../features/world/WorldView"; import { InstanceView } from "../features/world/InstanceView"; import { GroupView } from "../features/group/GroupView"; @@ -45,6 +47,7 @@ type NavItem = { onClick: () => void; kind?: View["kind"]; external?: boolean; + divider?: boolean; }; function Shell() { @@ -83,6 +86,14 @@ function Shell() { kind: "enhancements", onClick: () => nav.openEnhancements(), }, + { + id: "worlds", + label: t("nav:worlds"), + icon: Globe2, + kind: "worlds", + divider: true, + onClick: () => nav.openWorlds(), + }, { id: "account", label: t("nav:account"), @@ -95,6 +106,7 @@ function Shell() { label: t("nav:settings"), icon: SlidersHorizontal, kind: "settings", + divider: true, onClick: () => nav.openSettings(), }, { @@ -138,22 +150,24 @@ function Shell() { const Icon = item.icon; const active = !item.external && nav.current.kind === item.kind; return ( - + {item.label} + {item.external ? ( + + + + ) : null} + + ); })} @@ -177,6 +191,8 @@ function Shell() { ) : nav.current.kind === "group" ? ( + ) : nav.current.kind === "worlds" ? ( + ) : nav.current.kind === "account" ? ( ) : nav.current.kind === "settings" ? ( diff --git a/src/renderer/src/components/ui/Card.tsx b/src/renderer/src/components/ui/Card.tsx index 33f17c2..c9dba84 100644 --- a/src/renderer/src/components/ui/Card.tsx +++ b/src/renderer/src/components/ui/Card.tsx @@ -1,7 +1,7 @@ import type { ButtonHTMLAttributes } from "react"; const BASE = - "group block overflow-hidden rounded-lg border border-border bg-surface text-left transition-colors"; + "group block w-full overflow-hidden rounded-lg border border-border bg-surface text-left transition-colors"; export function Card({ className = "", diff --git a/src/renderer/src/features/game/LaunchButton.tsx b/src/renderer/src/features/game/LaunchButton.tsx index b25c2c0..7f9917b 100644 --- a/src/renderer/src/features/game/LaunchButton.tsx +++ b/src/renderer/src/features/game/LaunchButton.tsx @@ -1,39 +1,19 @@ -import { useEffect, useState } from "react"; import { ChevronRight } from "lucide-react"; import { Button } from "../../components/ui/Button"; -import { api, events } from "../../lib/api"; +import { api } from "../../lib/api"; import { useI18n } from "../../lib/i18n"; +import { useGameLaunch } from "./useGameLaunch"; export function LaunchButton() { const { t } = useI18n(); - const [running, setRunning] = useState(false); - const [supported, setSupported] = useState(true); - const [launching, setLaunching] = useState(false); - - useEffect(() => { - void api.game.status().then((s) => { - setRunning(s.running); - setSupported(s.supported); - }); - return events.on("game:changed", (s) => { - setRunning(s.running); - if (s.launching) setLaunching(true); - }); - }, []); - - useEffect(() => { - if (running) setLaunching(false); - }, [running]); + const { running, supported, launching, markLaunching } = useGameLaunch(); const onClick = async () => { if (launching) return; - if (!running) setLaunching(true); + if (!running) markLaunching(); try { - const s = await api.game.launch(); - setRunning(s.running); - } catch { - setLaunching(false); - } + await api.game.launch(); + } catch {} }; if (!supported) { diff --git a/src/renderer/src/features/game/useGameLaunch.ts b/src/renderer/src/features/game/useGameLaunch.ts new file mode 100644 index 0000000..3e58688 --- /dev/null +++ b/src/renderer/src/features/game/useGameLaunch.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; +import { api, events } from "../../lib/api"; + +export function useGameLaunch() { + const [running, setRunning] = useState(false); + const [supported, setSupported] = useState(true); + const [launching, setLaunching] = useState(false); + + useEffect(() => { + void api.game.status().then((s) => { + setRunning(s.running); + setSupported(s.supported); + }); + return events.on("game:changed", (s) => { + setRunning(s.running); + if (s.launching) setLaunching(true); + if (s.running) setLaunching(false); + }); + }, []); + + return { running, supported, launching, markLaunching: () => setLaunching(true) }; +} diff --git a/src/renderer/src/features/navigation/NavContext.tsx b/src/renderer/src/features/navigation/NavContext.tsx index 43cf559..da568ec 100644 --- a/src/renderer/src/features/navigation/NavContext.tsx +++ b/src/renderer/src/features/navigation/NavContext.tsx @@ -5,6 +5,7 @@ export type View = | { kind: "world"; id: string } | { kind: "instance"; worldId: string; instanceId: string; location: string } | { kind: "group"; id: string } + | { kind: "worlds" } | { kind: "account" } | { kind: "settings" } | { kind: "enhancements" } @@ -18,6 +19,7 @@ interface Nav { openWorld: (id: string) => void; openInstance: (worldId: string, instanceId: string, location: string) => void; openGroup: (id: string) => void; + openWorlds: () => void; openAccount: () => void; openSettings: () => void; openEnhancements: () => void; @@ -49,6 +51,7 @@ export function NavProvider({ children }: { children: React.ReactNode }) { openInstance: (worldId, instanceId, location) => push({ kind: "instance", worldId, instanceId, location }), openGroup: (id) => push({ kind: "group", id }), + openWorlds: () => root({ kind: "worlds" }), openAccount: () => root({ kind: "account" }), openSettings: () => root({ kind: "settings" }), openEnhancements: () => root({ kind: "enhancements" }), diff --git a/src/renderer/src/features/profile/DiscoverSection.tsx b/src/renderer/src/features/profile/DiscoverSection.tsx new file mode 100644 index 0000000..4566b9b --- /dev/null +++ b/src/renderer/src/features/profile/DiscoverSection.tsx @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type { DiscoverCategory } from "../../../../shared/types/world"; +import { Banner, SkeletonGrid } from "../../components/ui"; +import { useT } from "../../lib/i18n"; +import { api } from "../../lib/api"; +import { useAsync } from "../../lib/useAsync"; +import { WorldCard } from "./WorldsSection"; +import "./profile.css"; + +export function DiscoverSection() { + const t = useT(); + const state = useAsync(() => api.world.discover(), [], "Failed to load worlds."); + + if (state.status === "loading") { + return ( +
+ {Array.from({ length: 3 }, (_, i) => ( +
+
+ +
+ ))} +
+ ); + } + + if (state.status === "error") return {state.message}; + if (!state.data.length) + return

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

; + + return ( +
+ {state.data.map((cat) => ( + + ))} +
+ ); +} + +function Row({ category }: { category: DiscoverCategory }) { + const ref = useRef(null); + const [atStart, setAtStart] = useState(true); + const [atEnd, setAtEnd] = useState(false); + + const sync = useCallback(() => { + const el = ref.current; + if (!el) return; + const max = el.scrollWidth - el.clientWidth; + setAtStart(el.scrollLeft <= 1); + setAtEnd(el.scrollLeft >= max - 1); + }, []); + + useEffect(() => { + sync(); + const el = ref.current; + if (!el) return; + const ro = new ResizeObserver(sync); + ro.observe(el); + return () => ro.disconnect(); + }, [sync]); + + const page = useCallback((dir: 1 | -1) => { + const el = ref.current; + if (!el) return; + el.scrollBy({ left: dir * el.clientWidth * 0.85, behavior: "smooth" }); + }, []); + + return ( +
+

{category.name}

+ +
+ page(-1)} /> + page(1)} /> + +
+ {category.worlds.map((w) => ( +
+ +
+ ))} +
+
+
+ ); +} + +function Arrow({ + dir, + disabled, + onClick, +}: { + dir: 1 | -1; + disabled: boolean; + onClick: () => void; +}) { + const Icon = dir === 1 ? ChevronRight : ChevronLeft; + return ( + + ); +} diff --git a/src/renderer/src/features/profile/MyWorldsView.tsx b/src/renderer/src/features/profile/MyWorldsView.tsx new file mode 100644 index 0000000..db72a9a --- /dev/null +++ b/src/renderer/src/features/profile/MyWorldsView.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { Banner, Loader, Tabs } from "../../components/ui"; +import { useT } from "../../lib/i18n"; +import { useProfile } from "./useProfile"; +import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection"; +import { DiscoverSection } from "./DiscoverSection"; + +const SHELL = "mx-auto flex w-full max-w-[1100px] flex-col gap-5 px-12 pb-16 pt-10"; + +type Tab = "discover" | "worlds" | "favorites"; + +export function MyWorldsView() { + const t = useT(); + const state = useProfile("me"); + const [tab, setTab] = useState("discover"); + + if (state.status === "loading") return ; + if (state.status === "error") + return ( +
+ {state.message} +
+ ); + + const userId = state.profile.id; + return ( +
+
+

{t("nav:worlds")}

+
+ + + + {tab === "discover" ? ( +
+ +
+ ) : tab === "worlds" ? ( +
+ {(filter) => } +
+ ) : ( +
+ + {(filter) => } + +
+ )} +
+ ); +} diff --git a/src/renderer/src/features/profile/ProfileView.tsx b/src/renderer/src/features/profile/ProfileView.tsx index 29aab21..a4c5f6b 100644 --- a/src/renderer/src/features/profile/ProfileView.tsx +++ b/src/renderer/src/features/profile/ProfileView.tsx @@ -27,7 +27,7 @@ import { trustMeta, } from "../../lib/vrchat"; import { useProfile } from "./useProfile"; -import { WorldsSection, FavoriteWorldsSection } from "./WorldsSection"; +import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection"; import { GroupsSection } from "./GroupsSection"; import { LocationSection } from "./LocationSection"; import { COL, COL_WIDE } from "../../lib/layout"; @@ -290,16 +290,19 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null} - {/* don't hit tab endpoints until the tab opens */} {tab === "worlds" ? (
- + + {(filter) => } +
) : null} {tab === "favorites" ? (
- + + {(filter) => } +
) : null} diff --git a/src/renderer/src/features/profile/WorldsSection.tsx b/src/renderer/src/features/profile/WorldsSection.tsx index 63188c4..da28f82 100644 --- a/src/renderer/src/features/profile/WorldsSection.tsx +++ b/src/renderer/src/features/profile/WorldsSection.tsx @@ -1,26 +1,58 @@ +import { useMemo, useState } from "react"; import { Circle, Star, Users } from "lucide-react"; import type { World } from "../../../../shared/types/world"; -import { Card, CollapsibleCard, HoverImage, SkeletonGrid, Tag } from "../../components/ui"; +import { Card, CollapsibleCard, Field, HoverImage, SkeletonGrid, Tag } from "../../components/ui"; import { compactNumber } from "../../lib/format"; import { useT } from "../../lib/i18n"; import { useNav } from "../navigation/NavContext"; import { useUserWorlds } from "./useUserWorlds"; import { useFavoriteWorlds } from "./useFavoriteWorlds"; -export function WorldsSection({ userId }: { userId: string }) { +type WorldFilter = (world: World) => boolean; + +function matchWorld(query: string): WorldFilter { + const q = query.trim().toLowerCase(); + if (!q) return () => true; + const terms = q.split(/\s+/); + return (w) => { + const haystack = `${w.name} ${w.authorName} ${w.description} ${w.tags.join(" ")}`.toLowerCase(); + return terms.every((term) => haystack.includes(term)); + }; +} + +export function WorldSearch({ children }: { children: (filter: WorldFilter) => React.ReactNode }) { const t = useT(); - const { status, worlds, message } = useUserWorlds(userId); + const [query, setQuery] = useState(""); + const filter = useMemo(() => matchWorld(query), [query]); return ( - +
+ setQuery(e.target.value)} + /> + {children(filter)} +
); } -export function FavoriteWorldsSection({ userId }: { userId: string }) { +export function WorldsSection({ userId, filter }: { userId: string; filter?: WorldFilter }) { + const t = useT(); + const { status, worlds, message } = useUserWorlds(userId); + const shown = filter ? worlds.filter(filter) : worlds; + return ( + + ); +} + +export function FavoriteWorldsSection({ + userId, + filter, +}: { + userId: string; + filter?: WorldFilter; +}) { const t = useT(); const { status, folders } = useFavoriteWorlds(userId); const loading = status === "loading"; @@ -33,11 +65,15 @@ export function FavoriteWorldsSection({ userId }: { userId: string }) { ); } - if (!folders.length) return null; + const shown = filter + ? folders.map((f) => ({ ...f, worlds: f.worlds.filter(filter) })).filter((f) => f.worlds.length) + : folders; + + if (!shown.length) return null; return (
- {folders.map((folder) => ( + {shown.map((folder) => (
{folder.worlds.map((w) => ( @@ -109,7 +145,7 @@ function Wrap({ ); } -function WorldCard({ world }: { world: World }) { +export function WorldCard({ world }: { world: World }) { const t = useT(); const { openWorld } = useNav(); const img = world.thumbnailImageUrl || world.imageUrl; diff --git a/src/renderer/src/features/profile/profile.css b/src/renderer/src/features/profile/profile.css index e580d47..6b1e4b1 100644 --- a/src/renderer/src/features/profile/profile.css +++ b/src/renderer/src/features/profile/profile.css @@ -1,3 +1,58 @@ +.discover-row { + scrollbar-width: none; + scroll-padding-inline: 4px; +} +.discover-row::-webkit-scrollbar { + display: none; +} + +.discover-rail[data-at-start="false"] { + -webkit-mask-image: linear-gradient(to right, transparent, #000 28px); + mask-image: linear-gradient(to right, transparent, #000 28px); +} +.discover-rail[data-at-end="false"] { + -webkit-mask-image: linear-gradient(to left, transparent, #000 28px); + mask-image: linear-gradient(to left, transparent, #000 28px); +} +.discover-rail[data-at-start="false"][data-at-end="false"] { + -webkit-mask-image: linear-gradient( + to right, + transparent, + #000 28px, + #000 calc(100% - 28px), + transparent + ); + mask-image: linear-gradient( + to right, + transparent, + #000 28px, + #000 calc(100% - 28px), + transparent + ); +} +.discover-arrow { + opacity: 0; + pointer-events: none; + transform: translateY(-50%) scale(0.96); + transition: + opacity var(--dur) var(--ease), + transform var(--dur) var(--ease), + background var(--dur) var(--ease); +} +.discover-rail:hover .discover-arrow:not([disabled]) { + opacity: 1; + pointer-events: auto; + transform: translateY(-50%) scale(1); +} +.discover-rail:hover .discover-arrow:not([disabled]):hover { + transform: translateY(-50%) scale(1.07); +} +@media (prefers-reduced-motion: reduce) { + .discover-row { + scroll-behavior: auto; + } +} + .profile__banner { height: clamp(200px, 32vh, 340px); flex: none; diff --git a/src/renderer/src/features/world/CreateInstanceModal.tsx b/src/renderer/src/features/world/CreateInstanceModal.tsx index 242874e..edf8434 100644 --- a/src/renderer/src/features/world/CreateInstanceModal.tsx +++ b/src/renderer/src/features/world/CreateInstanceModal.tsx @@ -5,6 +5,7 @@ import { api, errorMessage } from "../../lib/api"; import { useCopied } from "../../lib/useCopied"; import { accessLabel, regionFlag, regionLabel } from "../../lib/vrchat"; import { useT } from "../../lib/i18n"; +import { useGameLaunch } from "../game/useGameLaunch"; import type { CreateInstanceInput, CreateInstanceType, @@ -185,20 +186,18 @@ function Picker({ function ResultView({ instance }: { instance: Instance }) { const t = useT(); + const { running, launching, markLaunching } = useGameLaunch(); const [inviteSent, setInviteSent] = useState(false); const [inviteError, setInviteError] = useState(null); - const [launching, setLaunching] = useState(false); const [launchError, setLaunchError] = useState(null); const launch = async () => { - setLaunching(true); + if (!running) markLaunching(); setLaunchError(null); try { await api.game.join(instance.location); } catch (err) { setLaunchError(errorMessage(err, "Failed to launch VRChat")); - } finally { - setLaunching(false); } }; @@ -220,9 +219,13 @@ function ResultView({ instance }: { instance: Instance }) { {canLaunch ? (
- {launchError ? {launchError} : null}
diff --git a/src/renderer/src/features/world/InstanceView.tsx b/src/renderer/src/features/world/InstanceView.tsx index 8385b91..3b36ba3 100644 --- a/src/renderer/src/features/world/InstanceView.tsx +++ b/src/renderer/src/features/world/InstanceView.tsx @@ -9,6 +9,7 @@ import { useNav } from "../navigation/NavContext"; import { useT } from "../../lib/i18n"; import { COL_WIDE } from "../../lib/layout"; import { accessLabel, regionFlag, regionLabel } from "../../lib/vrchat"; +import { useGameLaunch } from "../game/useGameLaunch"; import "../profile/profile.css"; export function InstanceView({ worldId, instanceId }: { worldId: string; instanceId: string }) { @@ -131,20 +132,18 @@ function InstanceCard({ function JoinActions({ instance }: { instance: Instance }) { const t = useT(); - const [joining, setJoining] = useState(false); + const { running, launching, markLaunching } = useGameLaunch(); const [joinError, setJoinError] = useState(null); const [inviteSent, setInviteSent] = useState(false); const [inviteError, setInviteError] = useState(null); const join = async () => { - setJoining(true); + if (!running) markLaunching(); setJoinError(null); try { await api.game.join(instance.location); } catch (err) { setJoinError(errorMessage(err, "Failed to launch VRChat")); - } finally { - setJoining(false); } }; @@ -168,9 +167,13 @@ function JoinActions({ instance }: { instance: Instance }) { {inviteSent ? t("world:instance.inviteSent") : t("world:instance.inviteMe")} {canLaunch ? ( - ) : null}
diff --git a/src/renderer/src/lib/api.ts b/src/renderer/src/lib/api.ts index 020ad1d..98211f3 100644 --- a/src/renderer/src/lib/api.ts +++ b/src/renderer/src/lib/api.ts @@ -58,6 +58,7 @@ export const api = { world: { byUser: (userId: string) => call("world:byUser", userId), search: (query: string) => call("world:search", query), + discover: () => call("world:discover"), favorites: (userId: string) => call("world:favorites", userId), get: (worldId: string) => call("world:get", worldId), snapshot: () => call("world:snapshot"), diff --git a/src/renderer/src/lib/i18n/locales/en/nav.json b/src/renderer/src/lib/i18n/locales/en/nav.json index eca1f9a..c695d19 100644 --- a/src/renderer/src/lib/i18n/locales/en/nav.json +++ b/src/renderer/src/lib/i18n/locales/en/nav.json @@ -2,6 +2,7 @@ "search": "Search", "gallery": "Gallery", "enhancements": "Enhancements", + "worlds": "Worlds", "account": "Account Settings", "settings": "Settings", "debug": "Debug", diff --git a/src/renderer/src/lib/i18n/locales/en/profile.json b/src/renderer/src/lib/i18n/locales/en/profile.json index b56226b..811e160 100644 --- a/src/renderer/src/lib/i18n/locales/en/profile.json +++ b/src/renderer/src/lib/i18n/locales/en/profile.json @@ -10,7 +10,8 @@ "addFriendError": "Couldn't send friend request.", "tabs": { "overview": "Overview", - "worlds": "Worlds", + "discover": "Discover", + "worlds": "Uploaded Worlds", "favorites": "Favorite Worlds", "groups": "Groups" }, @@ -48,7 +49,12 @@ "visits": "visits", "capacity": "capacity" }, - "visitsCount": "{{formattedCount}} visits" + "visitsCount": "{{formattedCount}} visits", + "search": { + "label": "Search worlds", + "placeholder": "Filter by name, author, or tag…" + }, + "empty": "No worlds to show." }, "groups": { "title": "Groups", diff --git a/src/renderer/src/lib/i18n/locales/en/world.json b/src/renderer/src/lib/i18n/locales/en/world.json index 6bae587..d45e3c4 100644 --- a/src/renderer/src/lib/i18n/locales/en/world.json +++ b/src/renderer/src/lib/i18n/locales/en/world.json @@ -27,6 +27,7 @@ "ready": "Instance created.", "launch": "Launch VRChat", "launching": "Launching…", + "running": "VRChat Running…", "selfInvite": "Invite myself", "selfInviteSent": "Invite sent. Check VRChat.", "lockedLink": "locked link", @@ -46,6 +47,7 @@ "queue": "In queue", "join": "Join in VRChat", "joining": "Launching…", + "running": "VRChat Running…", "inviteMe": "Invite me", "inviteSent": "Invite sent. Check VRChat.", "worldDetails": "View world details", diff --git a/src/renderer/src/lib/i18n/locales/ja/nav.json b/src/renderer/src/lib/i18n/locales/ja/nav.json index 02fd722..2ecfb21 100644 --- a/src/renderer/src/lib/i18n/locales/ja/nav.json +++ b/src/renderer/src/lib/i18n/locales/ja/nav.json @@ -2,6 +2,7 @@ "search": "検索", "gallery": "ギャラリー", "enhancements": "拡張機能", + "worlds": "ワールド", "account": "アカウント設定", "settings": "設定", "debug": "デバッグ", diff --git a/src/renderer/src/lib/i18n/locales/ja/profile.json b/src/renderer/src/lib/i18n/locales/ja/profile.json index f395547..ce3255b 100644 --- a/src/renderer/src/lib/i18n/locales/ja/profile.json +++ b/src/renderer/src/lib/i18n/locales/ja/profile.json @@ -10,7 +10,8 @@ "addFriendError": "フレンド申請を送信できませんでした。", "tabs": { "overview": "概要", - "worlds": "ワールド", + "discover": "見つける", + "worlds": "アップロードしたワールド", "favorites": "お気に入りのワールド", "groups": "グループ" }, @@ -48,7 +49,12 @@ "visits": "訪問数", "capacity": "定員" }, - "visitsCount": "{{formattedCount}} 回訪問" + "visitsCount": "{{formattedCount}} 回訪問", + "search": { + "label": "ワールドを検索", + "placeholder": "名前・作者・タグで絞り込み…" + }, + "empty": "表示するワールドがありません。" }, "groups": { "title": "グループ", diff --git a/src/renderer/src/lib/i18n/locales/ja/world.json b/src/renderer/src/lib/i18n/locales/ja/world.json index 250fc26..8a74503 100644 --- a/src/renderer/src/lib/i18n/locales/ja/world.json +++ b/src/renderer/src/lib/i18n/locales/ja/world.json @@ -27,6 +27,7 @@ "ready": "インスタンスを作成しました。", "launch": "VRChatを起動", "launching": "起動中…", + "running": "VRChat実行中…", "selfInvite": "自分を招待", "selfInviteSent": "招待を送信しました。VRChatを確認してください", "lockedLink": "ロック付きリンク", @@ -46,6 +47,7 @@ "queue": "待機列", "join": "VRChatで参加", "joining": "起動中…", + "running": "VRChat実行中…", "inviteMe": "自分を招待", "inviteSent": "招待を送信しました。VRChatを確認してください", "worldDetails": "ワールドの詳細を見る", diff --git a/src/renderer/src/lib/i18n/locales/th/nav.json b/src/renderer/src/lib/i18n/locales/th/nav.json index b610ee4..a6bf86c 100644 --- a/src/renderer/src/lib/i18n/locales/th/nav.json +++ b/src/renderer/src/lib/i18n/locales/th/nav.json @@ -2,6 +2,7 @@ "search": "ค้นหา", "gallery": "แกลเลอรี", "enhancements": "ส่วนเสริม", + "worlds": "โลก", "account": "ตั้งค่าบัญชี", "settings": "ตั้งค่า", "debug": "ดีบัก", diff --git a/src/renderer/src/lib/i18n/locales/th/profile.json b/src/renderer/src/lib/i18n/locales/th/profile.json index ddd1dd0..b8b22af 100644 --- a/src/renderer/src/lib/i18n/locales/th/profile.json +++ b/src/renderer/src/lib/i18n/locales/th/profile.json @@ -10,7 +10,8 @@ "addFriendError": "ส่งคำขอเป็นเพื่อนไม่สำเร็จ", "tabs": { "overview": "ภาพรวม", - "worlds": "เวิลด์", + "discover": "ค้นพบ", + "worlds": "เวิลด์ที่อัปโหลด", "favorites": "เวิลด์ที่ชื่นชอบ", "groups": "กลุ่ม" }, @@ -48,7 +49,12 @@ "visits": "การเข้าชม", "capacity": "ความจุ" }, - "visitsCount": "เข้าชม {{formattedCount}} ครั้ง" + "visitsCount": "เข้าชม {{formattedCount}} ครั้ง", + "search": { + "label": "ค้นหาโลก", + "placeholder": "กรองตามชื่อ ผู้สร้าง หรือแท็ก…" + }, + "empty": "ไม่มีโลกที่จะแสดง" }, "groups": { "title": "กลุ่ม", diff --git a/src/renderer/src/lib/i18n/locales/th/world.json b/src/renderer/src/lib/i18n/locales/th/world.json index 39b00cf..aa76681 100644 --- a/src/renderer/src/lib/i18n/locales/th/world.json +++ b/src/renderer/src/lib/i18n/locales/th/world.json @@ -27,6 +27,7 @@ "ready": "สร้างอินสแตนซ์แล้ว", "launch": "เปิด VRChat", "launching": "กำลังเปิด…", + "running": "VRChat กำลังทำงาน…", "selfInvite": "เชิญตัวเอง", "selfInviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat", "lockedLink": "ลิงก์ที่ล็อก", @@ -46,6 +47,7 @@ "queue": "อยู่ในคิว", "join": "เข้าร่วมใน VRChat", "joining": "กำลังเปิด…", + "running": "VRChat กำลังทำงาน…", "inviteMe": "เชิญตัวเอง", "inviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat", "worldDetails": "ดูรายละเอียดเวิลด์", diff --git a/src/renderer/src/styles/app-shell.css b/src/renderer/src/styles/app-shell.css index e267774..cb5060e 100644 --- a/src/renderer/src/styles/app-shell.css +++ b/src/renderer/src/styles/app-shell.css @@ -123,6 +123,11 @@ flex-direction: column; gap: 2px; } +.navitem-divider { + height: 1px; + margin: 6px 8px; + background: var(--border); +} .navitem { display: flex; align-items: center; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 179cfc3..465de6f 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -6,7 +6,7 @@ import type { TwoFactorPayload, } from "./types/auth"; import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user"; -import type { FavoriteWorldFolder, World, WorldSnapshot } from "./types/world"; +import type { DiscoverCategory, FavoriteWorldFolder, World, WorldSnapshot } from "./types/world"; import type { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance"; import type { UnityStatus } from "./types/unity"; import type { Avatar } from "./types/avatar"; @@ -48,6 +48,7 @@ export interface IpcRequests { "world:byUser": (userId: string) => IpcResult; "world:search": (query: string) => IpcResult; + "world:discover": () => IpcResult; "world:favorites": (userId: string) => IpcResult; "world:get": (worldId: string) => IpcResult; "world:snapshot": () => IpcResult; diff --git a/src/shared/types/world.ts b/src/shared/types/world.ts index 93e7ffc..6df5d02 100644 --- a/src/shared/types/world.ts +++ b/src/shared/types/world.ts @@ -45,3 +45,9 @@ export interface FavoriteWorldFolder { displayName: string; worldIds: string[]; } + +export interface DiscoverCategory { + id: string; + name: string; + worlds: World[]; +}