feat: World Discovery

This commit is contained in:
2026-06-29 02:33:22 +07:00
parent 716fb82ae1
commit 99ac7761ce
29 changed files with 535 additions and 83 deletions
+2
View File
@@ -5,6 +5,7 @@ export const policies = {
user: { ttl: 5 * 60_000, staleWhileRevalidate: 10 * 60_000 }, user: { ttl: 5 * 60_000, staleWhileRevalidate: 10 * 60_000 },
userSearch: { ttl: 5 * 60_000 }, userSearch: { ttl: 5 * 60_000 },
worldSearch: { 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 }, friends: { ttl: 5 * 60_000, staleWhileRevalidate: 60_000 },
userWorlds: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, userWorlds: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
favoriteWorlds: { 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()}`, userByName: (name: string) => `user:name:${name.toLowerCase()}`,
userSearch: (q: string) => `user:search:${q.trim().toLowerCase()}`, userSearch: (q: string) => `user:search:${q.trim().toLowerCase()}`,
worldSearch: (q: string) => `world:search:${q.trim().toLowerCase()}`, worldSearch: (q: string) => `world:search:${q.trim().toLowerCase()}`,
discover: () => "world:discover",
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}`,
+1
View File
@@ -50,6 +50,7 @@ const handlers = {
}), }),
"world:favorites": (userId) => guard(() => worlds.getFavoriteWorlds(userId)), "world:favorites": (userId) => guard(() => worlds.getFavoriteWorlds(userId)),
"world:search": (query) => guard(() => worlds.searchWorlds(query)), "world:search": (query) => guard(() => worlds.searchWorlds(query)),
"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()),
+88 -1
View File
@@ -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. // VRChat web routes that are missing from the SDK.
@@ -37,3 +37,90 @@ export async function getFavoriteGroupWorlds(
} }
return worlds; 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<WorldCategory[]> {
const { data } = await vrc.client.get<InfoPushEntry[], unknown, true>({
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<LimitedWorld[]> {
const tags = ["system_approved", category.tag].filter(Boolean).join(",");
const { data } = await vrc.client.get<LimitedWorld[], unknown, true>({
url: "/worlds",
query: {
releaseStatus: "public",
sort: category.sort,
order: category.order ?? "descending",
tag: tags || undefined,
featured: false,
n,
offset: 0,
},
throwOnError: true,
});
return data;
}
+27 -2
View File
@@ -1,7 +1,12 @@
import type { VRChat } from "vrchat"; 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 { httpStatusOf } from "./errors";
import { getFavoriteGroupWorlds, type WorldFavoriteGroupType } from "./rawEndpoints"; import {
getCategoryWorlds,
getFavoriteGroupWorlds,
getWorldCategories,
type WorldFavoriteGroupType,
} from "./rawEndpoints";
import { toWorld } from "./mappers"; import { toWorld } from "./mappers";
import { cachedRead } from "./cachedRead"; import { cachedRead } from "./cachedRead";
import { worldStore } from "../store/worldStore"; import { worldStore } from "../store/worldStore";
@@ -131,6 +136,26 @@ export async function searchWorlds(query: string): Promise<World[]> {
return worlds; return worlds;
} }
const DISCOVER_ROW_SIZE = 12;
export async function getDiscover(): Promise<DiscoverCategory[]> {
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<ReturnType<typeof getWorldCategories>>[number],
): Promise<DiscoverCategory> {
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<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),
+32 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { import {
ArrowLeft, ArrowLeft,
ChevronLeft, ChevronLeft,
@@ -6,6 +6,7 @@ import {
CircleDot, CircleDot,
ExternalLink, ExternalLink,
Images, Images,
Globe2,
Search, Search,
Settings, Settings,
SlidersHorizontal, SlidersHorizontal,
@@ -14,6 +15,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { ProfileView } from "../features/profile/ProfileView"; import { ProfileView } from "../features/profile/ProfileView";
import { MyWorldsView } from "../features/profile/MyWorldsView";
import { WorldView } from "../features/world/WorldView"; import { WorldView } from "../features/world/WorldView";
import { InstanceView } from "../features/world/InstanceView"; import { InstanceView } from "../features/world/InstanceView";
import { GroupView } from "../features/group/GroupView"; import { GroupView } from "../features/group/GroupView";
@@ -45,6 +47,7 @@ type NavItem = {
onClick: () => void; onClick: () => void;
kind?: View["kind"]; kind?: View["kind"];
external?: boolean; external?: boolean;
divider?: boolean;
}; };
function Shell() { function Shell() {
@@ -83,6 +86,14 @@ function Shell() {
kind: "enhancements", kind: "enhancements",
onClick: () => nav.openEnhancements(), onClick: () => nav.openEnhancements(),
}, },
{
id: "worlds",
label: t("nav:worlds"),
icon: Globe2,
kind: "worlds",
divider: true,
onClick: () => nav.openWorlds(),
},
{ {
id: "account", id: "account",
label: t("nav:account"), label: t("nav:account"),
@@ -95,6 +106,7 @@ function Shell() {
label: t("nav:settings"), label: t("nav:settings"),
icon: SlidersHorizontal, icon: SlidersHorizontal,
kind: "settings", kind: "settings",
divider: true,
onClick: () => nav.openSettings(), onClick: () => nav.openSettings(),
}, },
{ {
@@ -138,22 +150,24 @@ function Shell() {
const Icon = item.icon; const Icon = item.icon;
const active = !item.external && nav.current.kind === item.kind; const active = !item.external && nav.current.kind === item.kind;
return ( return (
<button <Fragment key={item.id}>
key={item.id} {item.divider ? <div className="navitem-divider" aria-hidden /> : null}
className={`navitem ${active ? "is-active" : ""}`} <button
onClick={item.onClick} className={`navitem ${active ? "is-active" : ""}`}
title={item.label} onClick={item.onClick}
> title={item.label}
<span className="navitem__ico"> >
<Icon size={16} /> <span className="navitem__ico">
</span> <Icon size={16} />
<span className="navitem__label">{item.label}</span>
{item.external ? (
<span className="navitem__hint">
<ExternalLink size={13} />
</span> </span>
) : null} <span className="navitem__label">{item.label}</span>
</button> {item.external ? (
<span className="navitem__hint">
<ExternalLink size={13} />
</span>
) : null}
</button>
</Fragment>
); );
})} })}
</nav> </nav>
@@ -177,6 +191,8 @@ function Shell() {
<InstanceView worldId={nav.current.worldId} instanceId={nav.current.instanceId} /> <InstanceView worldId={nav.current.worldId} instanceId={nav.current.instanceId} />
) : nav.current.kind === "group" ? ( ) : nav.current.kind === "group" ? (
<GroupView groupId={nav.current.id} /> <GroupView groupId={nav.current.id} />
) : nav.current.kind === "worlds" ? (
<MyWorldsView />
) : nav.current.kind === "account" ? ( ) : nav.current.kind === "account" ? (
<AccountSettingsView /> <AccountSettingsView />
) : nav.current.kind === "settings" ? ( ) : nav.current.kind === "settings" ? (
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ButtonHTMLAttributes } from "react"; import type { ButtonHTMLAttributes } from "react";
const BASE = 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({ export function Card({
className = "", className = "",
@@ -1,39 +1,19 @@
import { useEffect, useState } from "react";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import { api, events } from "../../lib/api"; import { api } from "../../lib/api";
import { useI18n } from "../../lib/i18n"; import { useI18n } from "../../lib/i18n";
import { useGameLaunch } from "./useGameLaunch";
export function LaunchButton() { export function LaunchButton() {
const { t } = useI18n(); const { t } = useI18n();
const [running, setRunning] = useState(false); const { running, supported, launching, markLaunching } = useGameLaunch();
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 onClick = async () => { const onClick = async () => {
if (launching) return; if (launching) return;
if (!running) setLaunching(true); if (!running) markLaunching();
try { try {
const s = await api.game.launch(); await api.game.launch();
setRunning(s.running); } catch {}
} catch {
setLaunching(false);
}
}; };
if (!supported) { if (!supported) {
@@ -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) };
}
@@ -5,6 +5,7 @@ export type View =
| { kind: "world"; id: string } | { kind: "world"; id: string }
| { kind: "instance"; worldId: string; instanceId: string; location: string } | { kind: "instance"; worldId: string; instanceId: string; location: string }
| { kind: "group"; id: string } | { kind: "group"; id: string }
| { kind: "worlds" }
| { kind: "account" } | { kind: "account" }
| { kind: "settings" } | { kind: "settings" }
| { kind: "enhancements" } | { kind: "enhancements" }
@@ -18,6 +19,7 @@ interface Nav {
openWorld: (id: string) => void; openWorld: (id: string) => void;
openInstance: (worldId: string, instanceId: string, location: string) => void; openInstance: (worldId: string, instanceId: string, location: string) => void;
openGroup: (id: string) => void; openGroup: (id: string) => void;
openWorlds: () => void;
openAccount: () => void; openAccount: () => void;
openSettings: () => void; openSettings: () => void;
openEnhancements: () => void; openEnhancements: () => void;
@@ -49,6 +51,7 @@ export function NavProvider({ children }: { children: React.ReactNode }) {
openInstance: (worldId, instanceId, location) => openInstance: (worldId, instanceId, location) =>
push({ kind: "instance", worldId, instanceId, location }), push({ kind: "instance", worldId, instanceId, location }),
openGroup: (id) => push({ kind: "group", id }), openGroup: (id) => push({ kind: "group", id }),
openWorlds: () => root({ kind: "worlds" }),
openAccount: () => root({ kind: "account" }), openAccount: () => root({ kind: "account" }),
openSettings: () => root({ kind: "settings" }), openSettings: () => root({ kind: "settings" }),
openEnhancements: () => root({ kind: "enhancements" }), openEnhancements: () => root({ kind: "enhancements" }),
@@ -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 (
<div className="flex flex-col gap-7">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="flex flex-col gap-3">
<div className="h-4 w-40 rounded-md bg-surface-hover" />
<SkeletonGrid count={4} />
</div>
))}
</div>
);
}
if (state.status === "error") return <Banner>{state.message}</Banner>;
if (!state.data.length)
return <p className="text-[13.5px] text-faint">{t("profile:worlds.empty")}</p>;
return (
<div className="flex flex-col gap-7">
{state.data.map((cat) => (
<Row key={cat.id} category={cat} />
))}
</div>
);
}
function Row({ category }: { category: DiscoverCategory }) {
const ref = useRef<HTMLDivElement>(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 (
<section className="flex flex-col gap-3">
<h3 className="text-[15px] font-semibold tracking-[-0.2px]">{category.name}</h3>
<div className="discover-rail relative" data-at-start={atStart} data-at-end={atEnd}>
<Arrow dir={-1} disabled={atStart} onClick={() => page(-1)} />
<Arrow dir={1} disabled={atEnd} onClick={() => page(1)} />
<div
ref={ref}
onScroll={sync}
className="discover-row -mx-1 flex snap-x scroll-smooth gap-3 overflow-x-auto px-1 pb-1"
>
{category.worlds.map((w) => (
<div key={w.id} className="w-[200px] shrink-0 snap-start">
<WorldCard world={w} />
</div>
))}
</div>
</div>
</section>
);
}
function Arrow({
dir,
disabled,
onClick,
}: {
dir: 1 | -1;
disabled: boolean;
onClick: () => void;
}) {
const Icon = dir === 1 ? ChevronRight : ChevronLeft;
return (
<button
type="button"
aria-label={dir === 1 ? "Scroll right" : "Scroll left"}
tabIndex={-1}
disabled={disabled}
onClick={onClick}
className={`discover-arrow absolute top-1/2 z-10 grid size-9 place-items-center rounded-full border border-border bg-surface/90 text-text shadow-md backdrop-blur hover:bg-surface ${
dir === 1 ? "right-1" : "left-1"
}`}
>
<Icon size={18} />
</button>
);
}
@@ -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<Tab>("discover");
if (state.status === "loading") return <Loader className="absolute inset-0" />;
if (state.status === "error")
return (
<div className={SHELL}>
<Banner>{state.message}</Banner>
</div>
);
const userId = state.profile.id;
return (
<div className={SHELL}>
<header>
<h1 className="text-[26px] font-bold tracking-[-0.4px]">{t("nav:worlds")}</h1>
</header>
<Tabs
tabs={[
{ id: "discover", label: t("profile:tabs.discover") },
{ id: "worlds", label: t("profile:tabs.worlds") },
{ id: "favorites", label: t("profile:tabs.favorites") },
]}
active={tab}
onChange={setTab}
/>
{tab === "discover" ? (
<div className="rise-in">
<DiscoverSection />
</div>
) : tab === "worlds" ? (
<div className="rise-in">
<WorldSearch>{(filter) => <WorldsSection userId={userId} filter={filter} />}</WorldSearch>
</div>
) : (
<div className="rise-in">
<WorldSearch>
{(filter) => <FavoriteWorldsSection userId={userId} filter={filter} />}
</WorldSearch>
</div>
)}
</div>
);
}
@@ -27,7 +27,7 @@ import {
trustMeta, trustMeta,
} from "../../lib/vrchat"; } from "../../lib/vrchat";
import { useProfile } from "./useProfile"; import { useProfile } from "./useProfile";
import { WorldsSection, FavoriteWorldsSection } from "./WorldsSection"; import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSection";
import { GroupsSection } from "./GroupsSection"; import { GroupsSection } from "./GroupsSection";
import { LocationSection } from "./LocationSection"; import { LocationSection } from "./LocationSection";
import { COL, COL_WIDE } from "../../lib/layout"; import { COL, COL_WIDE } from "../../lib/layout";
@@ -290,16 +290,19 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
</div> </div>
) : null} ) : null}
{/* don't hit tab endpoints until the tab opens */}
{tab === "worlds" ? ( {tab === "worlds" ? (
<div className="rise-in"> <div className="rise-in">
<WorldsSection userId={profile.id} /> <WorldSearch>
{(filter) => <WorldsSection userId={profile.id} filter={filter} />}
</WorldSearch>
</div> </div>
) : null} ) : null}
{tab === "favorites" ? ( {tab === "favorites" ? (
<div className="rise-in"> <div className="rise-in">
<FavoriteWorldsSection userId={profile.id} /> <WorldSearch>
{(filter) => <FavoriteWorldsSection userId={profile.id} filter={filter} />}
</WorldSearch>
</div> </div>
) : null} ) : null}
@@ -1,26 +1,58 @@
import { useMemo, useState } from "react";
import { Circle, Star, Users } from "lucide-react"; import { Circle, Star, Users } from "lucide-react";
import type { World } from "../../../../shared/types/world"; 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 { compactNumber } from "../../lib/format";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { useNav } from "../navigation/NavContext"; import { useNav } from "../navigation/NavContext";
import { useUserWorlds } from "./useUserWorlds"; import { useUserWorlds } from "./useUserWorlds";
import { useFavoriteWorlds } from "./useFavoriteWorlds"; 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 t = useT();
const { status, worlds, message } = useUserWorlds(userId); const [query, setQuery] = useState("");
const filter = useMemo(() => matchWorld(query), [query]);
return ( return (
<WorldGrid <div className="flex flex-col gap-5">
title={t("profile:worlds.title")} <Field
status={status} label={t("profile:worlds.search.label")}
worlds={worlds} placeholder={t("profile:worlds.search.placeholder")}
message={message} value={query}
/> onChange={(e) => setQuery(e.target.value)}
/>
{children(filter)}
</div>
); );
} }
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 (
<WorldGrid title={t("profile:worlds.title")} status={status} worlds={shown} message={message} />
);
}
export function FavoriteWorldsSection({
userId,
filter,
}: {
userId: string;
filter?: WorldFilter;
}) {
const t = useT(); const t = useT();
const { status, folders } = useFavoriteWorlds(userId); const { status, folders } = useFavoriteWorlds(userId);
const loading = status === "loading"; 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 ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{folders.map((folder) => ( {shown.map((folder) => (
<CollapsibleCard key={folder.name} title={folder.displayName} count={folder.worlds.length}> <CollapsibleCard key={folder.name} title={folder.displayName} count={folder.worlds.length}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{folder.worlds.map((w) => ( {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 t = useT();
const { openWorld } = useNav(); const { openWorld } = useNav();
const img = world.thumbnailImageUrl || world.imageUrl; const img = world.thumbnailImageUrl || world.imageUrl;
@@ -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 { .profile__banner {
height: clamp(200px, 32vh, 340px); height: clamp(200px, 32vh, 340px);
flex: none; flex: none;
@@ -5,6 +5,7 @@ import { api, errorMessage } from "../../lib/api";
import { useCopied } from "../../lib/useCopied"; import { useCopied } from "../../lib/useCopied";
import { accessLabel, regionFlag, regionLabel } from "../../lib/vrchat"; import { accessLabel, regionFlag, regionLabel } from "../../lib/vrchat";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { useGameLaunch } from "../game/useGameLaunch";
import type { import type {
CreateInstanceInput, CreateInstanceInput,
CreateInstanceType, CreateInstanceType,
@@ -185,20 +186,18 @@ function Picker<T extends string>({
function ResultView({ instance }: { instance: Instance }) { function ResultView({ instance }: { instance: Instance }) {
const t = useT(); const t = useT();
const { running, launching, markLaunching } = useGameLaunch();
const [inviteSent, setInviteSent] = useState(false); const [inviteSent, setInviteSent] = useState(false);
const [inviteError, setInviteError] = useState<string | null>(null); const [inviteError, setInviteError] = useState<string | null>(null);
const [launching, setLaunching] = useState(false);
const [launchError, setLaunchError] = useState<string | null>(null); const [launchError, setLaunchError] = useState<string | null>(null);
const launch = async () => { const launch = async () => {
setLaunching(true); if (!running) markLaunching();
setLaunchError(null); setLaunchError(null);
try { try {
await api.game.join(instance.location); await api.game.join(instance.location);
} catch (err) { } catch (err) {
setLaunchError(errorMessage(err, "Failed to launch VRChat")); setLaunchError(errorMessage(err, "Failed to launch VRChat"));
} finally {
setLaunching(false);
} }
}; };
@@ -220,9 +219,13 @@ function ResultView({ instance }: { instance: Instance }) {
{canLaunch ? ( {canLaunch ? (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Button variant="primary" onClick={launch} loading={launching} block> <Button variant="primary" onClick={launch} loading={launching} disabled={running} block>
{!launching ? <Play size={15} /> : null} {!launching ? <Play size={15} /> : null}
{launching ? t("world:create.launching") : t("world:create.launch")} {running
? t("world:create.running")
: launching
? t("world:create.launching")
: t("world:create.launch")}
</Button> </Button>
{launchError ? <span className="text-[12px] text-danger">{launchError}</span> : null} {launchError ? <span className="text-[12px] text-danger">{launchError}</span> : null}
</div> </div>
@@ -9,6 +9,7 @@ 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 { accessLabel, regionFlag, regionLabel } from "../../lib/vrchat"; import { accessLabel, regionFlag, regionLabel } from "../../lib/vrchat";
import { useGameLaunch } from "../game/useGameLaunch";
import "../profile/profile.css"; import "../profile/profile.css";
export function InstanceView({ worldId, instanceId }: { worldId: string; instanceId: string }) { export function InstanceView({ worldId, instanceId }: { worldId: string; instanceId: string }) {
@@ -131,20 +132,18 @@ function InstanceCard({
function JoinActions({ instance }: { instance: Instance }) { function JoinActions({ instance }: { instance: Instance }) {
const t = useT(); const t = useT();
const [joining, setJoining] = useState(false); const { running, launching, markLaunching } = useGameLaunch();
const [joinError, setJoinError] = useState<string | null>(null); const [joinError, setJoinError] = useState<string | null>(null);
const [inviteSent, setInviteSent] = useState(false); const [inviteSent, setInviteSent] = useState(false);
const [inviteError, setInviteError] = useState<string | null>(null); const [inviteError, setInviteError] = useState<string | null>(null);
const join = async () => { const join = async () => {
setJoining(true); if (!running) markLaunching();
setJoinError(null); setJoinError(null);
try { try {
await api.game.join(instance.location); await api.game.join(instance.location);
} catch (err) { } catch (err) {
setJoinError(errorMessage(err, "Failed to launch VRChat")); 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")} {inviteSent ? t("world:instance.inviteSent") : t("world:instance.inviteMe")}
</Button> </Button>
{canLaunch ? ( {canLaunch ? (
<Button variant="primary" onClick={join} loading={joining}> <Button variant="primary" onClick={join} loading={launching} disabled={running}>
{!joining ? <Play size={15} /> : null} {!launching ? <Play size={15} /> : null}
{joining ? t("world:instance.joining") : t("world:instance.join")} {running
? t("world:instance.running")
: launching
? t("world:instance.joining")
: t("world:instance.join")}
</Button> </Button>
) : null} ) : null}
</div> </div>
+1
View File
@@ -58,6 +58,7 @@ export const api = {
world: { world: {
byUser: (userId: string) => call("world:byUser", userId), byUser: (userId: string) => call("world:byUser", userId),
search: (query: string) => call("world:search", query), search: (query: string) => call("world:search", query),
discover: () => call("world:discover"),
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"),
@@ -2,6 +2,7 @@
"search": "Search", "search": "Search",
"gallery": "Gallery", "gallery": "Gallery",
"enhancements": "Enhancements", "enhancements": "Enhancements",
"worlds": "Worlds",
"account": "Account Settings", "account": "Account Settings",
"settings": "Settings", "settings": "Settings",
"debug": "Debug", "debug": "Debug",
@@ -10,7 +10,8 @@
"addFriendError": "Couldn't send friend request.", "addFriendError": "Couldn't send friend request.",
"tabs": { "tabs": {
"overview": "Overview", "overview": "Overview",
"worlds": "Worlds", "discover": "Discover",
"worlds": "Uploaded Worlds",
"favorites": "Favorite Worlds", "favorites": "Favorite Worlds",
"groups": "Groups" "groups": "Groups"
}, },
@@ -48,7 +49,12 @@
"visits": "visits", "visits": "visits",
"capacity": "capacity" "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": { "groups": {
"title": "Groups", "title": "Groups",
@@ -27,6 +27,7 @@
"ready": "Instance created.", "ready": "Instance created.",
"launch": "Launch VRChat", "launch": "Launch VRChat",
"launching": "Launching…", "launching": "Launching…",
"running": "VRChat Running…",
"selfInvite": "Invite myself", "selfInvite": "Invite myself",
"selfInviteSent": "Invite sent. Check VRChat.", "selfInviteSent": "Invite sent. Check VRChat.",
"lockedLink": "locked link", "lockedLink": "locked link",
@@ -46,6 +47,7 @@
"queue": "In queue", "queue": "In queue",
"join": "Join in VRChat", "join": "Join in VRChat",
"joining": "Launching…", "joining": "Launching…",
"running": "VRChat Running…",
"inviteMe": "Invite me", "inviteMe": "Invite me",
"inviteSent": "Invite sent. Check VRChat.", "inviteSent": "Invite sent. Check VRChat.",
"worldDetails": "View world details", "worldDetails": "View world details",
@@ -2,6 +2,7 @@
"search": "検索", "search": "検索",
"gallery": "ギャラリー", "gallery": "ギャラリー",
"enhancements": "拡張機能", "enhancements": "拡張機能",
"worlds": "ワールド",
"account": "アカウント設定", "account": "アカウント設定",
"settings": "設定", "settings": "設定",
"debug": "デバッグ", "debug": "デバッグ",
@@ -10,7 +10,8 @@
"addFriendError": "フレンド申請を送信できませんでした。", "addFriendError": "フレンド申請を送信できませんでした。",
"tabs": { "tabs": {
"overview": "概要", "overview": "概要",
"worlds": "ワールド", "discover": "見つける",
"worlds": "アップロードしたワールド",
"favorites": "お気に入りのワールド", "favorites": "お気に入りのワールド",
"groups": "グループ" "groups": "グループ"
}, },
@@ -48,7 +49,12 @@
"visits": "訪問数", "visits": "訪問数",
"capacity": "定員" "capacity": "定員"
}, },
"visitsCount": "{{formattedCount}} 回訪問" "visitsCount": "{{formattedCount}} 回訪問",
"search": {
"label": "ワールドを検索",
"placeholder": "名前・作者・タグで絞り込み…"
},
"empty": "表示するワールドがありません。"
}, },
"groups": { "groups": {
"title": "グループ", "title": "グループ",
@@ -27,6 +27,7 @@
"ready": "インスタンスを作成しました。", "ready": "インスタンスを作成しました。",
"launch": "VRChatを起動", "launch": "VRChatを起動",
"launching": "起動中…", "launching": "起動中…",
"running": "VRChat実行中…",
"selfInvite": "自分を招待", "selfInvite": "自分を招待",
"selfInviteSent": "招待を送信しました。VRChatを確認してください", "selfInviteSent": "招待を送信しました。VRChatを確認してください",
"lockedLink": "ロック付きリンク", "lockedLink": "ロック付きリンク",
@@ -46,6 +47,7 @@
"queue": "待機列", "queue": "待機列",
"join": "VRChatで参加", "join": "VRChatで参加",
"joining": "起動中…", "joining": "起動中…",
"running": "VRChat実行中…",
"inviteMe": "自分を招待", "inviteMe": "自分を招待",
"inviteSent": "招待を送信しました。VRChatを確認してください", "inviteSent": "招待を送信しました。VRChatを確認してください",
"worldDetails": "ワールドの詳細を見る", "worldDetails": "ワールドの詳細を見る",
@@ -2,6 +2,7 @@
"search": "ค้นหา", "search": "ค้นหา",
"gallery": "แกลเลอรี", "gallery": "แกลเลอรี",
"enhancements": "ส่วนเสริม", "enhancements": "ส่วนเสริม",
"worlds": "โลก",
"account": "ตั้งค่าบัญชี", "account": "ตั้งค่าบัญชี",
"settings": "ตั้งค่า", "settings": "ตั้งค่า",
"debug": "ดีบัก", "debug": "ดีบัก",
@@ -10,7 +10,8 @@
"addFriendError": "ส่งคำขอเป็นเพื่อนไม่สำเร็จ", "addFriendError": "ส่งคำขอเป็นเพื่อนไม่สำเร็จ",
"tabs": { "tabs": {
"overview": "ภาพรวม", "overview": "ภาพรวม",
"worlds": "เวิลด์", "discover": "ค้นพบ",
"worlds": "เวิลด์ที่อัปโหลด",
"favorites": "เวิลด์ที่ชื่นชอบ", "favorites": "เวิลด์ที่ชื่นชอบ",
"groups": "กลุ่ม" "groups": "กลุ่ม"
}, },
@@ -48,7 +49,12 @@
"visits": "การเข้าชม", "visits": "การเข้าชม",
"capacity": "ความจุ" "capacity": "ความจุ"
}, },
"visitsCount": "เข้าชม {{formattedCount}} ครั้ง" "visitsCount": "เข้าชม {{formattedCount}} ครั้ง",
"search": {
"label": "ค้นหาโลก",
"placeholder": "กรองตามชื่อ ผู้สร้าง หรือแท็ก…"
},
"empty": "ไม่มีโลกที่จะแสดง"
}, },
"groups": { "groups": {
"title": "กลุ่ม", "title": "กลุ่ม",
@@ -27,6 +27,7 @@
"ready": "สร้างอินสแตนซ์แล้ว", "ready": "สร้างอินสแตนซ์แล้ว",
"launch": "เปิด VRChat", "launch": "เปิด VRChat",
"launching": "กำลังเปิด…", "launching": "กำลังเปิด…",
"running": "VRChat กำลังทำงาน…",
"selfInvite": "เชิญตัวเอง", "selfInvite": "เชิญตัวเอง",
"selfInviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat", "selfInviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat",
"lockedLink": "ลิงก์ที่ล็อก", "lockedLink": "ลิงก์ที่ล็อก",
@@ -46,6 +47,7 @@
"queue": "อยู่ในคิว", "queue": "อยู่ในคิว",
"join": "เข้าร่วมใน VRChat", "join": "เข้าร่วมใน VRChat",
"joining": "กำลังเปิด…", "joining": "กำลังเปิด…",
"running": "VRChat กำลังทำงาน…",
"inviteMe": "เชิญตัวเอง", "inviteMe": "เชิญตัวเอง",
"inviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat", "inviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat",
"worldDetails": "ดูรายละเอียดเวิลด์", "worldDetails": "ดูรายละเอียดเวิลด์",
+5
View File
@@ -123,6 +123,11 @@
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
} }
.navitem-divider {
height: 1px;
margin: 6px 8px;
background: var(--border);
}
.navitem { .navitem {
display: flex; display: flex;
align-items: center; align-items: center;
+2 -1
View File
@@ -6,7 +6,7 @@ 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 { 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 { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance";
import type { UnityStatus } from "./types/unity"; import type { UnityStatus } from "./types/unity";
import type { Avatar } from "./types/avatar"; import type { Avatar } from "./types/avatar";
@@ -48,6 +48,7 @@ export interface IpcRequests {
"world:byUser": (userId: string) => IpcResult<World[]>; "world:byUser": (userId: string) => IpcResult<World[]>;
"world:search": (query: string) => IpcResult<World[]>; "world:search": (query: string) => IpcResult<World[]>;
"world:discover": () => IpcResult<DiscoverCategory[]>;
"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>;
+6
View File
@@ -45,3 +45,9 @@ export interface FavoriteWorldFolder {
displayName: string; displayName: string;
worldIds: string[]; worldIds: string[];
} }
export interface DiscoverCategory {
id: string;
name: string;
worlds: World[];
}