mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
✨ feat: World Discovery
This commit is contained in:
Vendored
+2
@@ -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}`,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
@@ -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<World[]> {
|
||||
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[]> {
|
||||
const worlds = await cachedRead(
|
||||
cacheKeys.userWorlds(userId),
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`navitem ${active ? "is-active" : ""}`}
|
||||
onClick={item.onClick}
|
||||
title={item.label}
|
||||
>
|
||||
<span className="navitem__ico">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<span className="navitem__label">{item.label}</span>
|
||||
{item.external ? (
|
||||
<span className="navitem__hint">
|
||||
<ExternalLink size={13} />
|
||||
<Fragment key={item.id}>
|
||||
{item.divider ? <div className="navitem-divider" aria-hidden /> : null}
|
||||
<button
|
||||
className={`navitem ${active ? "is-active" : ""}`}
|
||||
onClick={item.onClick}
|
||||
title={item.label}
|
||||
>
|
||||
<span className="navitem__ico">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="navitem__label">{item.label}</span>
|
||||
{item.external ? (
|
||||
<span className="navitem__hint">
|
||||
<ExternalLink size={13} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@@ -177,6 +191,8 @@ function Shell() {
|
||||
<InstanceView worldId={nav.current.worldId} instanceId={nav.current.instanceId} />
|
||||
) : nav.current.kind === "group" ? (
|
||||
<GroupView groupId={nav.current.id} />
|
||||
) : nav.current.kind === "worlds" ? (
|
||||
<MyWorldsView />
|
||||
) : nav.current.kind === "account" ? (
|
||||
<AccountSettingsView />
|
||||
) : nav.current.kind === "settings" ? (
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: "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" }),
|
||||
|
||||
@@ -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,
|
||||
} 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 }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* don't hit tab endpoints until the tab opens */}
|
||||
{tab === "worlds" ? (
|
||||
<div className="rise-in">
|
||||
<WorldsSection userId={profile.id} />
|
||||
<WorldSearch>
|
||||
{(filter) => <WorldsSection userId={profile.id} filter={filter} />}
|
||||
</WorldSearch>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "favorites" ? (
|
||||
<div className="rise-in">
|
||||
<FavoriteWorldsSection userId={profile.id} />
|
||||
<WorldSearch>
|
||||
{(filter) => <FavoriteWorldsSection userId={profile.id} filter={filter} />}
|
||||
</WorldSearch>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<WorldGrid
|
||||
title={t("profile:worlds.title")}
|
||||
status={status}
|
||||
worlds={worlds}
|
||||
message={message}
|
||||
/>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Field
|
||||
label={t("profile:worlds.search.label")}
|
||||
placeholder={t("profile:worlds.search.placeholder")}
|
||||
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 { 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 (
|
||||
<div className="flex flex-col gap-5">
|
||||
{folders.map((folder) => (
|
||||
{shown.map((folder) => (
|
||||
<CollapsibleCard key={folder.name} title={folder.displayName} count={folder.worlds.length}>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T extends string>({
|
||||
|
||||
function ResultView({ instance }: { instance: Instance }) {
|
||||
const t = useT();
|
||||
const { running, launching, markLaunching } = useGameLaunch();
|
||||
const [inviteSent, setInviteSent] = useState(false);
|
||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||
const [launching, setLaunching] = useState(false);
|
||||
const [launchError, setLaunchError] = useState<string | null>(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 ? (
|
||||
<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 ? t("world:create.launching") : t("world:create.launch")}
|
||||
{running
|
||||
? t("world:create.running")
|
||||
: launching
|
||||
? t("world:create.launching")
|
||||
: t("world:create.launch")}
|
||||
</Button>
|
||||
{launchError ? <span className="text-[12px] text-danger">{launchError}</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [inviteSent, setInviteSent] = useState(false);
|
||||
const [inviteError, setInviteError] = useState<string | null>(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")}
|
||||
</Button>
|
||||
{canLaunch ? (
|
||||
<Button variant="primary" onClick={join} loading={joining}>
|
||||
{!joining ? <Play size={15} /> : null}
|
||||
{joining ? t("world:instance.joining") : t("world:instance.join")}
|
||||
<Button variant="primary" onClick={join} loading={launching} disabled={running}>
|
||||
{!launching ? <Play size={15} /> : null}
|
||||
{running
|
||||
? t("world:instance.running")
|
||||
: launching
|
||||
? t("world:instance.joining")
|
||||
: t("world:instance.join")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"search": "Search",
|
||||
"gallery": "Gallery",
|
||||
"enhancements": "Enhancements",
|
||||
"worlds": "Worlds",
|
||||
"account": "Account Settings",
|
||||
"settings": "Settings",
|
||||
"debug": "Debug",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"search": "検索",
|
||||
"gallery": "ギャラリー",
|
||||
"enhancements": "拡張機能",
|
||||
"worlds": "ワールド",
|
||||
"account": "アカウント設定",
|
||||
"settings": "設定",
|
||||
"debug": "デバッグ",
|
||||
|
||||
@@ -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": "グループ",
|
||||
|
||||
@@ -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": "ワールドの詳細を見る",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"search": "ค้นหา",
|
||||
"gallery": "แกลเลอรี",
|
||||
"enhancements": "ส่วนเสริม",
|
||||
"worlds": "โลก",
|
||||
"account": "ตั้งค่าบัญชี",
|
||||
"settings": "ตั้งค่า",
|
||||
"debug": "ดีบัก",
|
||||
|
||||
@@ -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": "กลุ่ม",
|
||||
|
||||
@@ -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": "ดูรายละเอียดเวิลด์",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-1
@@ -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[]>;
|
||||
"world:search": (query: string) => IpcResult<World[]>;
|
||||
"world:discover": () => IpcResult<DiscoverCategory[]>;
|
||||
"world:favorites": (userId: string) => IpcResult<FavoriteWorldFolder[]>;
|
||||
"world:get": (worldId: string) => IpcResult<World>;
|
||||
"world:snapshot": () => IpcResult<WorldSnapshot>;
|
||||
|
||||
@@ -45,3 +45,9 @@ export interface FavoriteWorldFolder {
|
||||
displayName: string;
|
||||
worldIds: string[];
|
||||
}
|
||||
|
||||
export interface DiscoverCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
worlds: World[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user