feat: Add instance view

This commit is contained in:
2026-06-29 01:03:58 +07:00
parent d9ed86fe77
commit 42db5d9946
17 changed files with 617 additions and 103 deletions
+3
View File
@@ -15,6 +15,7 @@ import {
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 { WorldView } from "../features/world/WorldView"; import { WorldView } from "../features/world/WorldView";
import { InstanceView } from "../features/world/InstanceView";
import { GroupView } from "../features/group/GroupView"; import { GroupView } from "../features/group/GroupView";
import { AccountSettingsView } from "../features/account/AccountSettingsView"; import { AccountSettingsView } from "../features/account/AccountSettingsView";
import { SearchView } from "../features/search/SearchView"; import { SearchView } from "../features/search/SearchView";
@@ -164,6 +165,8 @@ function Shell() {
<div key={stageKey} className="stage__inner animate-rise"> <div key={stageKey} className="stage__inner animate-rise">
{nav.current.kind === "world" ? ( {nav.current.kind === "world" ? (
<WorldView worldId={nav.current.id} /> <WorldView worldId={nav.current.id} />
) : nav.current.kind === "instance" ? (
<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 === "account" ? ( ) : nav.current.kind === "account" ? (
@@ -3,6 +3,7 @@ import { createContext, useContext, useMemo, useState } from "react";
export type View = export type View =
| { kind: "user"; id: "me" | string } | { kind: "user"; id: "me" | string }
| { kind: "world"; id: string } | { kind: "world"; id: string }
| { kind: "instance"; worldId: string; instanceId: string; location: string }
| { kind: "group"; id: string } | { kind: "group"; id: string }
| { kind: "account" } | { kind: "account" }
| { kind: "settings" } | { kind: "settings" }
@@ -15,6 +16,7 @@ interface Nav {
canBack: boolean; canBack: boolean;
openUser: (id: "me" | string) => void; openUser: (id: "me" | string) => void;
openWorld: (id: string) => void; openWorld: (id: string) => void;
openInstance: (worldId: string, instanceId: string, location: string) => void;
openGroup: (id: string) => void; openGroup: (id: string) => void;
openAccount: () => void; openAccount: () => void;
openSettings: () => void; openSettings: () => void;
@@ -44,6 +46,8 @@ export function NavProvider({ children }: { children: React.ReactNode }) {
canBack: stack.length > 1, canBack: stack.length > 1,
openUser: (id) => push({ kind: "user", id }), openUser: (id) => push({ kind: "user", id }),
openWorld: (id) => push({ kind: "world", id }), openWorld: (id) => push({ kind: "world", id }),
openInstance: (worldId, instanceId, location) =>
push({ kind: "instance", worldId, instanceId, location }),
openGroup: (id) => push({ kind: "group", id }), openGroup: (id) => push({ kind: "group", id }),
openAccount: () => root({ kind: "account" }), openAccount: () => root({ kind: "account" }),
openSettings: () => root({ kind: "settings" }), openSettings: () => root({ kind: "settings" }),
@@ -62,6 +66,7 @@ function sameView(a: View, b: View): boolean {
if (a.kind !== b.kind) return false; if (a.kind !== b.kind) return false;
if (a.kind === "user" && b.kind === "user") return a.id === b.id; if (a.kind === "user" && b.kind === "user") return a.id === b.id;
if (a.kind === "world" && b.kind === "world") return a.id === b.id; if (a.kind === "world" && b.kind === "world") return a.id === b.id;
if (a.kind === "instance" && b.kind === "instance") return a.location === b.location;
if (a.kind === "group" && b.kind === "group") return a.id === b.id; if (a.kind === "group" && b.kind === "group") return a.id === b.id;
return true; return true;
} }
@@ -1,6 +1,7 @@
import { Star, Users } from "lucide-react"; import { Star, Users } from "lucide-react";
import type { Group } from "../../../../shared/types/group"; import type { Group } from "../../../../shared/types/group";
import { Avatar, CollapsibleCard, SkeletonGrid } from "../../components/ui"; import { Avatar, CollapsibleCard, SkeletonGrid } from "../../components/ui";
import { useT } from "../../lib/i18n";
import { useNav } from "../navigation/NavContext"; import { useNav } from "../navigation/NavContext";
import { useUserGroups } from "./useUserGroups"; import { useUserGroups } from "./useUserGroups";
@@ -41,6 +42,7 @@ export function GroupsSection({ userId }: { userId: string }) {
} }
function FeaturedGroup({ group }: { group: Group }) { function FeaturedGroup({ group }: { group: Group }) {
const t = useT();
const { openGroup } = useNav(); const { openGroup } = useNav();
return ( return (
<button <button
@@ -62,7 +64,7 @@ function FeaturedGroup({ group }: { group: Group }) {
<Avatar src={group.iconUrl} name={group.name} size={52} className="!rounded-xl" /> <Avatar src={group.iconUrl} name={group.name} size={52} className="!rounded-xl" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-accent"> <div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-accent">
<Star size={12} fill="currentColor" /> Featured group <Star size={12} fill="currentColor" /> {t("profile:groups.featured")}
</div> </div>
<div className="mt-0.5 truncate text-[15px] font-semibold" title={group.name}> <div className="mt-0.5 truncate text-[15px] font-semibold" title={group.name}>
{group.name} {group.name}
@@ -106,8 +108,9 @@ function GroupMeta({ group }: { group: Group }) {
} }
function Wrap({ count, children }: { count: number | string; children: React.ReactNode }) { function Wrap({ count, children }: { count: number | string; children: React.ReactNode }) {
const t = useT();
return ( return (
<CollapsibleCard title="Groups" count={count}> <CollapsibleCard title={t("profile:groups.title")} count={count}>
{children} {children}
</CollapsibleCard> </CollapsibleCard>
); );
@@ -5,10 +5,12 @@ import { useWorld } from "../../store/worlds";
import { useNav } from "../navigation/NavContext"; import { useNav } from "../navigation/NavContext";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { useAsync } from "../../lib/useAsync"; import { useAsync } from "../../lib/useAsync";
import { locationLabel, regionLabels, regionFlag } from "../../lib/vrchat"; import { useT } from "../../lib/i18n";
import { locationLabel, regionLabel, regionFlag } from "../../lib/vrchat";
export function LocationSection({ location }: { location?: Location }) { export function LocationSection({ location }: { location?: Location }) {
const { openWorld } = useNav(); const t = useT();
const { openInstance } = useNav();
const parsed = parseLocation(location); const parsed = parseLocation(location);
const world = useWorld(parsed?.worldId); const world = useWorld(parsed?.worldId);
const instance = useAsync( const instance = useAsync(
@@ -33,14 +35,12 @@ export function LocationSection({ location }: { location?: Location }) {
if (parsed && world) { if (parsed && world) {
const img = world.thumbnailImageUrl || world.imageUrl; const img = world.thumbnailImageUrl || world.imageUrl;
const region = parsed.region const region = regionLabel(t, parsed.region);
? (regionLabels[parsed.region] ?? parsed.region.toUpperCase())
: null;
const flag = regionFlag(parsed.region); const flag = regionFlag(parsed.region);
return ( return (
<Wrap> <Wrap>
<button <button
onClick={() => openWorld(world.id)} onClick={() => openInstance(parsed.worldId, parsed.instance, location as string)}
className="group flex w-full items-center gap-3.5 rounded-lg p-1 text-left transition-colors hover:bg-surface-hover" className="group flex w-full items-center gap-3.5 rounded-lg p-1 text-left transition-colors hover:bg-surface-hover"
> >
<div className="size-16 shrink-0 overflow-hidden rounded-lg bg-surface-hover"> <div className="size-16 shrink-0 overflow-hidden rounded-lg bg-surface-hover">
@@ -51,8 +51,13 @@ export function LocationSection({ location }: { location?: Location }) {
{world.name} {world.name}
</div> </div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-faint"> <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-faint">
<span className="truncate">by {world.authorName}</span> <span className="truncate">
<span className="inline-flex items-center gap-1" title="Instance"> {t("profile:location.by", { author: world.authorName })}
</span>
<span
className="inline-flex items-center gap-1"
title={t("profile:location.instance")}
>
<Hash size={11} /> <Hash size={11} />
{parsed.instanceId} {parsed.instanceId}
</span> </span>
@@ -71,12 +76,13 @@ export function LocationSection({ location }: { location?: Location }) {
className="inline-flex items-center gap-1" className="inline-flex items-center gap-1"
style={{ color: "var(--status-active)" }} style={{ color: "var(--status-active)" }}
> >
<Users size={11} /> {inInstance.userCount} in instance <Users size={11} />{" "}
{t("profile:location.inInstance", { n: inInstance.userCount })}
</span> </span>
) : null} ) : null}
{world.occupants > 0 ? ( {world.occupants > 0 ? (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<Users size={11} /> {world.occupants} in world <Users size={11} /> {t("profile:location.inWorld", { n: world.occupants })}
</span> </span>
) : null} ) : null}
</div> </div>
@@ -103,10 +109,11 @@ export function LocationSection({ location }: { location?: Location }) {
} }
function Wrap({ children }: { children: React.ReactNode }) { function Wrap({ children }: { children: React.ReactNode }) {
const t = useT();
return ( return (
<section className="rounded-xl border border-border bg-surface-2 p-4 shadow-sm"> <section className="rounded-xl border border-border bg-surface-2 p-4 shadow-sm">
<h3 className="mb-2.5 flex items-center gap-1.5 px-1 text-[11px] font-semibold uppercase tracking-wide text-faint"> <h3 className="mb-2.5 flex items-center gap-1.5 px-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
<MapPin size={12} /> Currently in <MapPin size={12} /> {t("profile:location.title")}
</h3> </h3>
{children} {children}
</section> </section>
@@ -56,7 +56,9 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
const status = statusMeta[profile.status]; const status = statusMeta[profile.status];
const presenceStatus = online ? status : statusMeta.offline; const presenceStatus = online ? status : statusMeta.offline;
const statusLabel = const statusLabel =
!online && profile.status !== "offline" ? `Offline (${status.label})` : status.label; !online && profile.status !== "offline"
? t("profile:status.offlineWas", { status: status.label })
: status.label;
const avatar = avatarOf(profile); const avatar = avatarOf(profile);
const banner = bannerOf(profile); const banner = bannerOf(profile);
const devLabel = profile.developerType ? developerLabels[profile.developerType] : undefined; const devLabel = profile.developerType ? developerLabels[profile.developerType] : undefined;
@@ -83,12 +85,14 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
<div className="min-w-0 flex-1 pb-2"> <div className="min-w-0 flex-1 pb-2">
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
<h2 className="text-[32px] font-bold tracking-[-0.6px]">{profile.displayName}</h2> <h2 className="text-[32px] font-bold tracking-[-0.6px]">{profile.displayName}</h2>
{profile.isSelf ? <Tag color="var(--accent)">You</Tag> : null} {profile.isSelf ? <Tag color="var(--accent)">{t("profile:badge.you")}</Tag> : null}
{profile.isFriend && !profile.isSelf ? ( {profile.isFriend && !profile.isSelf ? (
<Tag color="var(--status-join)">Friend</Tag> <Tag color="var(--status-join)">{t("profile:badge.friend")}</Tag>
) : null} ) : null}
{devLabel ? <Tag color="var(--accent)">{devLabel}</Tag> : null} {devLabel ? <Tag color="var(--accent)">{devLabel}</Tag> : null}
{profile.ageVerified ? <Tag color="var(--trust-trusted)">18+ Verified</Tag> : null} {profile.ageVerified ? (
<Tag color="var(--trust-trusted)">{t("profile:badge.ageVerified")}</Tag>
) : null}
</div> </div>
<div className="mt-2.5 flex flex-wrap items-center gap-3"> <div className="mt-2.5 flex flex-wrap items-center gap-3">
<Tag color={trust.color}>{trust.label}</Tag> <Tag color={trust.color}>{trust.label}</Tag>
@@ -142,10 +146,10 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
<Tabs <Tabs
tabs={[ tabs={[
{ id: "overview", label: "Overview" }, { id: "overview", label: t("profile:tabs.overview") },
{ id: "worlds", label: "Worlds" }, { id: "worlds", label: t("profile:tabs.worlds") },
{ id: "favorites", label: "Favorite Worlds" }, { id: "favorites", label: t("profile:tabs.favorites") },
{ id: "groups", label: "Groups" }, { id: "groups", label: t("profile:tabs.groups") },
]} ]}
active={tab} active={tab}
onChange={setTab} onChange={setTab}
@@ -154,7 +158,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
{tab === "overview" ? ( {tab === "overview" ? (
<div className="flex flex-col gap-5 rise-in"> <div className="flex flex-col gap-5 rise-in">
{profile.note ? ( {profile.note ? (
<Section title="Your note"> <Section title={t("profile:sections.note")}>
<p className="text-[14px] leading-relaxed whitespace-pre-wrap text-text"> <p className="text-[14px] leading-relaxed whitespace-pre-wrap text-text">
{profile.note} {profile.note}
</p> </p>
@@ -162,7 +166,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null} ) : null}
{profile.statusDescription || profile.bio || bioLinks.length ? ( {profile.statusDescription || profile.bio || bioLinks.length ? (
<Section title="About"> <Section title={t("profile:sections.about")}>
{profile.statusDescription ? ( {profile.statusDescription ? (
<p className="text-[15px] italic text-text">{profile.statusDescription}</p> <p className="text-[15px] italic text-text">{profile.statusDescription}</p>
) : null} ) : null}
@@ -184,27 +188,41 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null} ) : null}
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2"> <div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2">
<Section title="Details"> <Section title={t("profile:sections.details")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3"> <dl className="grid grid-cols-2 gap-x-6 gap-y-3">
{profile.dateJoined ? ( {profile.dateJoined ? (
<Fact label="Joined" value={formatDate(profile.dateJoined)} /> <Fact
label={t("profile:facts.joined")}
value={formatDate(profile.dateJoined)}
/>
) : null} ) : null}
{profile.lastLogin ? ( {profile.lastLogin ? (
<Fact label="Last login" value={formatDateTime(profile.lastLogin)} /> <Fact
label={t("profile:facts.lastLogin")}
value={formatDateTime(profile.lastLogin)}
/>
) : null} ) : null}
{profile.lastActivity ? ( {profile.lastActivity ? (
<Fact label="Last activity" value={formatDateTime(profile.lastActivity)} /> <Fact
label={t("profile:facts.lastActivity")}
value={formatDateTime(profile.lastActivity)}
/>
) : null} ) : null}
{profile.lastPlatform ? ( {profile.lastPlatform ? (
<Fact label="Platform" value={platformLabel(profile.lastPlatform)} /> <Fact
label={t("profile:facts.platform")}
value={platformLabel(profile.lastPlatform)}
/>
) : null} ) : null}
{profile.state ? <Fact label="State" value={stateLabel(profile.state)} /> : null} {profile.state ? (
<Fact label="User ID" value={profile.id} mono /> <Fact label={t("profile:facts.state")} value={stateLabel(profile.state, t)} />
) : null}
<Fact label={t("profile:facts.userId")} value={profile.id} mono />
</dl> </dl>
</Section> </Section>
{profile.languages?.length ? ( {profile.languages?.length ? (
<Section title="Languages"> <Section title={t("profile:sections.languages")}>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{profile.languages.map((code) => ( {profile.languages.map((code) => (
<Tag key={code}>{languageLabel(code)}</Tag> <Tag key={code}>{languageLabel(code)}</Tag>
@@ -214,7 +232,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null} ) : null}
{profile.currentAvatarTags?.length ? ( {profile.currentAvatarTags?.length ? (
<Section title="Avatar tags"> <Section title={t("profile:sections.avatarTags")}>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{profile.currentAvatarTags.map((t) => ( {profile.currentAvatarTags.map((t) => (
<Tag key={t}>{prettyTag(t, "content_")}</Tag> <Tag key={t}>{prettyTag(t, "content_")}</Tag>
@@ -224,7 +242,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null} ) : null}
{profile.badges?.length ? ( {profile.badges?.length ? (
<Section title="Badges"> <Section title={t("profile:sections.badges")}>
<div className="flex flex-wrap gap-2.5"> <div className="flex flex-wrap gap-2.5">
{[...profile.badges] {[...profile.badges]
.sort((a, b) => Number(b.showcased) - Number(a.showcased)) .sort((a, b) => Number(b.showcased) - Number(a.showcased))
@@ -250,7 +268,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
</div> </div>
{profile.pastDisplayNames?.length ? ( {profile.pastDisplayNames?.length ? (
<Section title="Former names" collapsible> <Section title={t("profile:sections.formerNames")} collapsible>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{profile.pastDisplayNames.map((p) => ( {profile.pastDisplayNames.map((p) => (
<span <span
@@ -261,7 +279,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
{p.updatedAt ? ( {p.updatedAt ? (
<em className="text-xs not-italic text-faint"> <em className="text-xs not-italic text-faint">
{" "} {" "}
· until {formatDate(p.updatedAt)} · {t("profile:formerName.until", { date: formatDate(p.updatedAt) })}
</em> </em>
) : null} ) : null}
</span> </span>
@@ -315,6 +333,7 @@ interface AddFriend {
} }
function useAddFriend(userId: string): AddFriend { function useAddFriend(userId: string): AddFriend {
const t = useT();
const [state, setState] = useState<AddFriendState>("idle"); const [state, setState] = useState<AddFriendState>("idle");
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -325,10 +344,10 @@ function useAddFriend(userId: string): AddFriend {
.add(userId) .add(userId)
.then(() => setState("sent")) .then(() => setState("sent"))
.catch((err) => { .catch((err) => {
setError(errorMessage(err, "Couldn't send friend request.")); setError(errorMessage(err, t("profile:addFriendError")));
setState("idle"); setState("idle");
}); });
}, [userId]); }, [userId, t]);
return { state, error, send }; return { state, error, send };
} }
@@ -386,8 +405,8 @@ function prettyLink(url: string): string {
} }
} }
function stateLabel(state: NonNullable<UserProfile["state"]>): string { function stateLabel(state: NonNullable<UserProfile["state"]>, t: ReturnType<typeof useT>): string {
if (state === "online") return "In VRChat"; if (state === "online") return t("profile:state.online");
if (state === "active") return "On website / mobile"; if (state === "active") return t("profile:state.active");
return "Offline"; return t("profile:state.offline");
} }
@@ -2,22 +2,32 @@ 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, HoverImage, SkeletonGrid, Tag } from "../../components/ui";
import { compactNumber } from "../../lib/format"; import { compactNumber } from "../../lib/format";
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 }) { export function WorldsSection({ userId }: { userId: string }) {
const t = useT();
const { status, worlds, message } = useUserWorlds(userId); const { status, worlds, message } = useUserWorlds(userId);
return <WorldGrid title="Worlds" status={status} worlds={worlds} message={message} />; return (
<WorldGrid
title={t("profile:worlds.title")}
status={status}
worlds={worlds}
message={message}
/>
);
} }
export function FavoriteWorldsSection({ userId }: { userId: string }) { export function FavoriteWorldsSection({ userId }: { userId: string }) {
const t = useT();
const { status, folders } = useFavoriteWorlds(userId); const { status, folders } = useFavoriteWorlds(userId);
const loading = status === "loading"; const loading = status === "loading";
if (loading && !folders.length) { if (loading && !folders.length) {
return ( return (
<CollapsibleCard title="Favorite worlds" count="…"> <CollapsibleCard title={t("profile:worlds.favorites")} count="…">
<SkeletonGrid count={3} /> <SkeletonGrid count={3} />
</CollapsibleCard> </CollapsibleCard>
); );
@@ -100,6 +110,7 @@ function Wrap({
} }
function WorldCard({ world }: { world: World }) { function WorldCard({ world }: { world: World }) {
const t = useT();
const { openWorld } = useNav(); const { openWorld } = useNav();
const img = world.thumbnailImageUrl || world.imageUrl; const img = world.thumbnailImageUrl || world.imageUrl;
return ( return (
@@ -117,22 +128,27 @@ function WorldCard({ world }: { world: World }) {
{world.name} {world.name}
</div> </div>
<div className="mt-1 flex flex-wrap items-center gap-3 text-[11px] tabular-nums text-faint"> <div className="mt-1 flex flex-wrap items-center gap-3 text-[11px] tabular-nums text-faint">
<span className="inline-flex items-center gap-1" title="favorites"> <span
className="inline-flex items-center gap-1"
title={t("profile:worlds.tip.favorites")}
>
<Star size={12} /> {compactNumber(world.favorites)} <Star size={12} /> {compactNumber(world.favorites)}
</span> </span>
{world.occupants > 0 ? ( {world.occupants > 0 ? (
<span <span
className="inline-flex items-center gap-1" className="inline-flex items-center gap-1"
title="players online now" title={t("profile:worlds.tip.online")}
style={{ color: "var(--status-active)" }} style={{ color: "var(--status-active)" }}
> >
<Circle size={9} fill="currentColor" /> {compactNumber(world.occupants)} <Circle size={9} fill="currentColor" /> {compactNumber(world.occupants)}
</span> </span>
) : null} ) : null}
{world.visits > 0 ? ( {world.visits > 0 ? (
<span title="visits">{compactNumber(world.visits)} visits</span> <span title={t("profile:worlds.tip.visits")}>
{t("profile:worlds.visitsCount", { formattedCount: compactNumber(world.visits) })}
</span>
) : null} ) : null}
<span className="inline-flex items-center gap-1" title="capacity"> <span className="inline-flex items-center gap-1" title={t("profile:worlds.tip.capacity")}>
<Users size={12} /> {world.capacity} <Users size={12} /> {world.capacity}
</span> </span>
</div> </div>
@@ -25,7 +25,7 @@ import type { AppConfig, PreferredRegion, RegionPing } from "../../../../shared/
import type { UnityStatus } from "../../../../shared/types/unity"; import type { UnityStatus } from "../../../../shared/types/unity";
import { api, errorMessage } from "../../lib/api"; import { api, errorMessage } from "../../lib/api";
import { useAsync } from "../../lib/useAsync"; import { useAsync } from "../../lib/useAsync";
import { regionFlag, regionLabels } from "../../lib/vrchat"; import { regionFlag, regionLabel } from "../../lib/vrchat";
import { Button, Field, Tabs } from "../../components/ui"; import { Button, Field, Tabs } from "../../components/ui";
const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10"; const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10";
@@ -420,9 +420,7 @@ function RegionSection() {
.reduce<(RegionPing & { ms: number }) | null>((a, b) => (!a || b.ms < a.ms ? b : a), null); .reduce<(RegionPing & { ms: number }) | null>((a, b) => (!a || b.ms < a.ms ? b : a), null);
const label = (r: PreferredRegion) => const label = (r: PreferredRegion) =>
r === "auto" r === "auto" ? t("settings:region.auto") : `${regionFlag(r) ?? ""} ${regionLabel(t, r)}`.trim();
? t("settings:region.auto")
: `${regionFlag(r) ?? ""} ${regionLabels[r] ?? r.toUpperCase()}`.trim();
return ( return (
<Section <Section
@@ -463,7 +461,7 @@ function RegionSection() {
{pings.map((p) => ( {pings.map((p) => (
<div key={p.region} className="flex items-center gap-2 text-[13px]"> <div key={p.region} className="flex items-center gap-2 text-[13px]">
<span className="w-28 shrink-0 text-muted"> <span className="w-28 shrink-0 text-muted">
{regionFlag(p.region) ?? ""} {regionLabels[p.region] ?? p.region.toUpperCase()} {regionFlag(p.region) ?? ""} {regionLabel(t, p.region)}
</span> </span>
{p.ms === null ? ( {p.ms === null ? (
<span className="text-faint">{t("settings:region.unreachable")}</span> <span className="text-faint">{t("settings:region.unreachable")}</span>
@@ -3,7 +3,7 @@ import { Check, Copy, Globe, Play, Send, Sparkles } from "lucide-react";
import { Banner, Button, Modal } from "../../components/ui"; import { Banner, Button, Modal } from "../../components/ui";
import { api, errorMessage } from "../../lib/api"; import { api, errorMessage } from "../../lib/api";
import { useCopied } from "../../lib/useCopied"; import { useCopied } from "../../lib/useCopied";
import { regionFlag } from "../../lib/vrchat"; import { accessLabel, regionFlag, regionLabel } from "../../lib/vrchat";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import type { import type {
CreateInstanceInput, CreateInstanceInput,
@@ -116,14 +116,14 @@ export function CreateInstanceModal({
value={type} value={type}
options={TYPES} options={TYPES}
onChange={setType} onChange={setType}
render={(v) => t(`world:create.types.${v}`)} render={(v) => accessLabel(t, v)}
/> />
<Picker <Picker
label={t("world:create.region")} label={t("world:create.region")}
value={region} value={region}
options={REGIONS} options={REGIONS}
onChange={pickRegion} onChange={pickRegion}
render={(v) => `${regionFlag(v) ?? ""} ${t(`world:create.regions.${v}`)}`.trim()} render={(v) => `${regionFlag(v) ?? ""} ${regionLabel(t, v)}`.trim()}
hint={ hint={
detecting ? ( detecting ? (
<span className="inline-flex items-center gap-1.5 text-accent"> <span className="inline-flex items-center gap-1.5 text-accent">
@@ -134,7 +134,7 @@ export function CreateInstanceModal({
<span className="inline-flex items-center gap-1.5 text-faint"> <span className="inline-flex items-center gap-1.5 text-faint">
<Sparkles size={11} /> <Sparkles size={11} />
{t("world:create.autoDetected", { {t("world:create.autoDetected", {
region: t(`world:create.regions.${region}`), region: regionLabel(t, region),
})} })}
</span> </span>
) : undefined ) : undefined
@@ -0,0 +1,200 @@
import { useState } from "react";
import { Check, ChevronRight, Globe, Play, Send, Users } from "lucide-react";
import type { Instance } from "../../../../shared/types/instance";
import { Banner, Button, Fact, Section, Skeleton, StatTile } from "../../components/ui";
import { api, errorMessage } from "../../lib/api";
import { useAsync } from "../../lib/useAsync";
import { useWorld } from "../../store/worlds";
import { useNav } from "../navigation/NavContext";
import { useT } from "../../lib/i18n";
import { COL_WIDE } from "../../lib/layout";
import { accessLabel, regionFlag, regionLabel } from "../../lib/vrchat";
import "../profile/profile.css";
export function InstanceView({ worldId, instanceId }: { worldId: string; instanceId: string }) {
const t = useT();
const world = useWorld(worldId);
const load = useAsync(
() => api.instance.get(worldId, instanceId),
[worldId, instanceId],
t("world:instance.unavailable"),
);
if (load.status === "error") {
return <Banner className="m-10 max-w-[420px]">{load.message}</Banner>;
}
if (load.status !== "ready") return <InstanceSkeleton />;
return <InstanceCard instance={load.data} world={world} worldId={worldId} />;
}
function InstanceCard({
instance,
world,
worldId,
}: {
instance: Instance;
world: ReturnType<typeof useWorld>;
worldId: string;
}) {
const t = useT();
const { openWorld } = useNav();
const banner = world?.imageUrl || world?.thumbnailImageUrl;
const thumb = world?.thumbnailImageUrl || world?.imageUrl;
const region = regionLabel(t, instance.region);
const access = accessLabel(t, instance.type);
const flag = regionFlag(instance.region);
return (
<article className="profile flex min-h-full w-full flex-col bg-surface pb-12">
<div
className="profile__banner"
style={{ backgroundImage: banner ? `url(${banner})` : undefined }}
/>
<div className={`${COL_WIDE} relative flex items-end gap-5`} style={{ marginTop: -64 }}>
<div className="world__thumb">{thumb ? <img src={thumb} alt="" /> : null}</div>
<div className="min-w-0 pb-1">
<p className="text-[12px] font-semibold uppercase tracking-wide text-faint">
{t("world:instance.title")}
</p>
<h2 className="text-[30px] font-bold leading-tight tracking-[-0.6px]">
{world?.name ?? worldId}
</h2>
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[14px] text-muted">
<span>{access}</span>
{region ? (
<span className="inline-flex items-center gap-1">
{flag ? (
<span className="text-[15px] leading-none">{flag}</span>
) : (
<Globe size={13} />
)}
{region}
</span>
) : null}
<span className="font-mono text-[12px] text-faint">#{instance.instanceId}</span>
</p>
</div>
<JoinActions instance={instance} />
</div>
<div className={`${COL_WIDE} mt-6 flex flex-col gap-5`}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile
icon={<Users size={15} />}
label={t("world:instance.players")}
value={`${instance.userCount}`}
live={instance.userCount > 0}
/>
<StatTile
icon={<Users size={15} />}
label={t("world:instance.capacity")}
value={`${instance.capacity}`}
/>
<StatTile
icon={<Globe size={15} />}
label={t("world:instance.region")}
value={region ? `${flag ?? ""} ${region}`.trim() : "—"}
/>
<StatTile
icon={<Users size={15} />}
label={t("world:instance.queue")}
value={`${instance.queueSize ?? 0}`}
/>
</div>
<button
onClick={() => openWorld(worldId)}
className="group flex w-full items-center gap-2 rounded-xl border border-border bg-surface-2 px-5 py-4 text-left shadow-sm transition-colors hover:bg-surface-hover"
>
<Globe size={16} className="shrink-0 text-faint" />
<span className="flex-1 text-[14px] font-semibold">
{t("world:instance.worldDetails")}
</span>
<ChevronRight
size={18}
className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5 group-hover:text-accent"
/>
</button>
<Section title={t("world:instance.title")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
<Fact label={t("world:instance.access")} value={access} />
{region ? <Fact label={t("world:instance.region")} value={region} /> : null}
<Fact label={t("world:instance.instanceId")} value={instance.instanceId} mono />
</dl>
</Section>
</div>
</article>
);
}
function JoinActions({ instance }: { instance: Instance }) {
const t = useT();
const [joining, setJoining] = useState(false);
const [joinError, setJoinError] = useState<string | null>(null);
const [inviteSent, setInviteSent] = useState(false);
const [inviteError, setInviteError] = useState<string | null>(null);
const join = async () => {
setJoining(true);
setJoinError(null);
try {
await api.game.join(instance.location);
} catch (err) {
setJoinError(errorMessage(err, "Failed to launch VRChat"));
} finally {
setJoining(false);
}
};
const inviteMe = async () => {
setInviteError(null);
try {
await api.instance.inviteSelf(instance.worldId, instance.instanceId);
setInviteSent(true);
} catch (err) {
setInviteError(errorMessage(err, "Failed to send invite"));
}
};
return (
<div className="mb-1 ml-auto flex shrink-0 flex-col items-end gap-1.5">
<div className="flex gap-1.5">
<Button variant="ghost" onClick={inviteMe} disabled={inviteSent}>
{inviteSent ? <Check size={15} /> : <Send size={15} />}
{inviteSent ? t("world:instance.inviteSent") : t("world:instance.inviteMe")}
</Button>
<Button variant="primary" onClick={join} loading={joining}>
{!joining ? <Play size={15} /> : null}
{joining ? t("world:instance.joining") : t("world:instance.join")}
</Button>
</div>
{joinError ? <span className="text-[12px] text-danger">{joinError}</span> : null}
{inviteError ? <span className="text-[12px] text-danger">{inviteError}</span> : null}
</div>
);
}
function InstanceSkeleton() {
return (
<div
className="profile profile--skeleton flex min-h-full w-full flex-col bg-surface pb-12"
aria-busy
>
<div className="profile__banner" />
<div className={`${COL_WIDE} relative flex items-end gap-5`} style={{ marginTop: -64 }}>
<Skeleton className="world__thumb" />
<div className="flex-1 pb-1">
<Skeleton className="h-[22px] w-2/5 rounded-lg" />
<Skeleton className="mt-3 h-3 w-1/4 rounded-lg" />
</div>
</div>
<div className={`${COL_WIDE} mt-6 grid grid-cols-4 gap-3`}>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[72px] rounded-xl" />
))}
</div>
</div>
);
}
@@ -0,0 +1,64 @@
{
"badge": {
"you": "You",
"friend": "Friend",
"ageVerified": "18+ Verified"
},
"status": {
"offlineWas": "Offline ({{status}})"
},
"addFriendError": "Couldn't send friend request.",
"tabs": {
"overview": "Overview",
"worlds": "Worlds",
"favorites": "Favorite Worlds",
"groups": "Groups"
},
"sections": {
"note": "Your note",
"about": "About",
"details": "Details",
"languages": "Languages",
"avatarTags": "Avatar tags",
"badges": "Badges",
"formerNames": "Former names"
},
"facts": {
"joined": "Joined",
"lastLogin": "Last login",
"lastActivity": "Last activity",
"platform": "Platform",
"state": "State",
"userId": "User ID"
},
"formerName": {
"until": "until {{date}}"
},
"state": {
"online": "In VRChat",
"active": "On website / mobile",
"offline": "Offline"
},
"worlds": {
"title": "Worlds",
"favorites": "Favorite worlds",
"tip": {
"favorites": "favorites",
"online": "players online now",
"visits": "visits",
"capacity": "capacity"
},
"visitsCount": "{{formattedCount}} visits"
},
"groups": {
"title": "Groups",
"featured": "Featured group"
},
"location": {
"title": "Currently in",
"by": "by {{author}}",
"instance": "Instance",
"inInstance": "{{n}} in instance",
"inWorld": "{{n}} in world"
}
}
+31 -13
View File
@@ -1,5 +1,21 @@
{ {
"createInstance": "Create Instance", "createInstance": "Create Instance",
"regions": {
"us": "US West",
"use": "US East",
"eu": "Europe",
"jp": "Japan"
},
"accessTypes": {
"public": "Public",
"hidden": "Friends+",
"friends": "Friends",
"private": "Invite",
"inviteRequest": "Invite+",
"group": "Group",
"groupPlus": "Group+",
"groupPublic": "Group Public"
},
"create": { "create": {
"title": "Create Instance", "title": "Create Instance",
"type": "Access", "type": "Access",
@@ -8,19 +24,6 @@
"creating": "Creating…", "creating": "Creating…",
"detecting": "Detecting best region…", "detecting": "Detecting best region…",
"autoDetected": "Auto: {{region}}", "autoDetected": "Auto: {{region}}",
"types": {
"public": "Public",
"friends+": "Friends+",
"friends": "Friends",
"invite": "Invite",
"invite+": "Invite+"
},
"regions": {
"us": "US West",
"use": "US East",
"eu": "Europe",
"jp": "Japan"
},
"ready": "Instance created.", "ready": "Instance created.",
"launch": "Launch VRChat", "launch": "Launch VRChat",
"launching": "Launching…", "launching": "Launching…",
@@ -32,5 +35,20 @@
"unlockedHint": "Bypasses the instance rules. Anyone with it can join. Not recommended.", "unlockedHint": "Bypasses the instance rules. Anyone with it can join. Not recommended.",
"copy": "Copy", "copy": "Copy",
"copied": "Copied" "copied": "Copied"
},
"instance": {
"title": "Instance",
"unavailable": "This instance is no longer available.",
"players": "Players",
"capacity": "Capacity",
"region": "Region",
"access": "Access",
"queue": "In queue",
"join": "Join in VRChat",
"joining": "Launching…",
"inviteMe": "Invite me",
"inviteSent": "Invite sent. Check VRChat.",
"worldDetails": "View world details",
"instanceId": "Instance ID"
} }
} }
@@ -0,0 +1,64 @@
{
"badge": {
"you": "あなた",
"friend": "フレンド",
"ageVerified": "18歳以上確認済み"
},
"status": {
"offlineWas": "オフライン({{status}}"
},
"addFriendError": "フレンド申請を送信できませんでした。",
"tabs": {
"overview": "概要",
"worlds": "ワールド",
"favorites": "お気に入りのワールド",
"groups": "グループ"
},
"sections": {
"note": "あなたのメモ",
"about": "自己紹介",
"details": "詳細",
"languages": "言語",
"avatarTags": "アバタータグ",
"badges": "バッジ",
"formerNames": "以前の名前"
},
"facts": {
"joined": "登録日",
"lastLogin": "最終ログイン",
"lastActivity": "最終アクティビティ",
"platform": "プラットフォーム",
"state": "状態",
"userId": "ユーザーID"
},
"formerName": {
"until": "{{date}}まで"
},
"state": {
"online": "VRChat内",
"active": "ウェブ/モバイル",
"offline": "オフライン"
},
"worlds": {
"title": "ワールド",
"favorites": "お気に入りのワールド",
"tip": {
"favorites": "お気に入り数",
"online": "現在のオンラインプレイヤー",
"visits": "訪問数",
"capacity": "定員"
},
"visitsCount": "{{formattedCount}} 回訪問"
},
"groups": {
"title": "グループ",
"featured": "代表グループ"
},
"location": {
"title": "現在地",
"by": "作者: {{author}}",
"instance": "インスタンス",
"inInstance": "インスタンスに{{n}}人",
"inWorld": "ワールドに{{n}}人"
}
}
+31 -13
View File
@@ -1,5 +1,21 @@
{ {
"createInstance": "インスタンスを作成", "createInstance": "インスタンスを作成",
"regions": {
"us": "米国西部",
"use": "米国東部",
"eu": "ヨーロッパ",
"jp": "日本"
},
"accessTypes": {
"public": "パブリック",
"hidden": "フレンド+",
"friends": "フレンド",
"private": "インバイト",
"inviteRequest": "インバイト+",
"group": "グループ",
"groupPlus": "グループ+",
"groupPublic": "グループパブリック"
},
"create": { "create": {
"title": "インスタンスを作成", "title": "インスタンスを作成",
"type": "アクセス", "type": "アクセス",
@@ -8,19 +24,6 @@
"creating": "作成中…", "creating": "作成中…",
"detecting": "最適なリージョンを検出中…", "detecting": "最適なリージョンを検出中…",
"autoDetected": "自動: {{region}}", "autoDetected": "自動: {{region}}",
"types": {
"public": "パブリック",
"friends+": "フレンド+",
"friends": "フレンド",
"invite": "インバイト",
"invite+": "インバイト+"
},
"regions": {
"us": "米国西部",
"use": "米国東部",
"eu": "ヨーロッパ",
"jp": "日本"
},
"ready": "インスタンスを作成しました。", "ready": "インスタンスを作成しました。",
"launch": "VRChatを起動", "launch": "VRChatを起動",
"launching": "起動中…", "launching": "起動中…",
@@ -32,5 +35,20 @@
"unlockedHint": "インスタンスのルールを無視します。リンクを持つ誰でも参加できます。推奨しません。", "unlockedHint": "インスタンスのルールを無視します。リンクを持つ誰でも参加できます。推奨しません。",
"copy": "コピー", "copy": "コピー",
"copied": "コピーしました" "copied": "コピーしました"
},
"instance": {
"title": "インスタンス",
"unavailable": "このインスタンスはもう利用できません。",
"players": "プレイヤー",
"capacity": "定員",
"region": "リージョン",
"access": "アクセス",
"queue": "待機列",
"join": "VRChatで参加",
"joining": "起動中…",
"inviteMe": "自分を招待",
"inviteSent": "招待を送信しました。VRChatを確認してください",
"worldDetails": "ワールドの詳細を見る",
"instanceId": "インスタンスID"
} }
} }
@@ -0,0 +1,64 @@
{
"badge": {
"you": "คุณ",
"friend": "เพื่อน",
"ageVerified": "ยืนยันอายุ 18+"
},
"status": {
"offlineWas": "ออฟไลน์ ({{status}})"
},
"addFriendError": "ส่งคำขอเป็นเพื่อนไม่สำเร็จ",
"tabs": {
"overview": "ภาพรวม",
"worlds": "เวิลด์",
"favorites": "เวิลด์ที่ชื่นชอบ",
"groups": "กลุ่ม"
},
"sections": {
"note": "บันทึกของคุณ",
"about": "เกี่ยวกับ",
"details": "รายละเอียด",
"languages": "ภาษา",
"avatarTags": "แท็กอวตาร",
"badges": "เหรียญตรา",
"formerNames": "ชื่อเดิม"
},
"facts": {
"joined": "เข้าร่วมเมื่อ",
"lastLogin": "เข้าสู่ระบบล่าสุด",
"lastActivity": "กิจกรรมล่าสุด",
"platform": "แพลตฟอร์ม",
"state": "สถานะ",
"userId": "รหัสผู้ใช้"
},
"formerName": {
"until": "จนถึง {{date}}"
},
"state": {
"online": "อยู่ใน VRChat",
"active": "บนเว็บ / มือถือ",
"offline": "ออฟไลน์"
},
"worlds": {
"title": "เวิลด์",
"favorites": "เวิลด์ที่ชื่นชอบ",
"tip": {
"favorites": "รายการโปรด",
"online": "ผู้เล่นออนไลน์ตอนนี้",
"visits": "การเข้าชม",
"capacity": "ความจุ"
},
"visitsCount": "เข้าชม {{formattedCount}} ครั้ง"
},
"groups": {
"title": "กลุ่ม",
"featured": "กลุ่มที่นำเสนอ"
},
"location": {
"title": "อยู่ที่",
"by": "โดย {{author}}",
"instance": "อินสแตนซ์",
"inInstance": "{{n}} คนในอินสแตนซ์",
"inWorld": "{{n}} คนในเวิลด์"
}
}
+31 -13
View File
@@ -1,5 +1,21 @@
{ {
"createInstance": "สร้างอินสแตนซ์", "createInstance": "สร้างอินสแตนซ์",
"regions": {
"us": "สหรัฐฯ ฝั่งตะวันตก",
"use": "สหรัฐฯ ฝั่งตะวันออก",
"eu": "ยุโรป",
"jp": "ญี่ปุ่น"
},
"accessTypes": {
"public": "สาธารณะ",
"hidden": "เพื่อน+",
"friends": "เพื่อน",
"private": "เชิญ",
"inviteRequest": "เชิญ+",
"group": "กลุ่ม",
"groupPlus": "กลุ่ม+",
"groupPublic": "กลุ่มสาธารณะ"
},
"create": { "create": {
"title": "สร้างอินสแตนซ์", "title": "สร้างอินสแตนซ์",
"type": "การเข้าถึง", "type": "การเข้าถึง",
@@ -8,19 +24,6 @@
"creating": "กำลังสร้าง…", "creating": "กำลังสร้าง…",
"detecting": "กำลังตรวจหาภูมิภาคที่ดีที่สุด…", "detecting": "กำลังตรวจหาภูมิภาคที่ดีที่สุด…",
"autoDetected": "อัตโนมัติ: {{region}}", "autoDetected": "อัตโนมัติ: {{region}}",
"types": {
"public": "สาธารณะ",
"friends+": "เพื่อน+",
"friends": "เพื่อน",
"invite": "เชิญ",
"invite+": "เชิญ+"
},
"regions": {
"us": "สหรัฐฯ ฝั่งตะวันตก",
"use": "สหรัฐฯ ฝั่งตะวันออก",
"eu": "ยุโรป",
"jp": "ญี่ปุ่น"
},
"ready": "สร้างอินสแตนซ์แล้ว", "ready": "สร้างอินสแตนซ์แล้ว",
"launch": "เปิด VRChat", "launch": "เปิด VRChat",
"launching": "กำลังเปิด…", "launching": "กำลังเปิด…",
@@ -32,5 +35,20 @@
"unlockedHint": "ข้ามกฎของอินสแตนซ์ ใครก็ตามที่มีลิงก์สามารถเข้าร่วมได้ ไม่แนะนำ", "unlockedHint": "ข้ามกฎของอินสแตนซ์ ใครก็ตามที่มีลิงก์สามารถเข้าร่วมได้ ไม่แนะนำ",
"copy": "คัดลอก", "copy": "คัดลอก",
"copied": "คัดลอกแล้ว" "copied": "คัดลอกแล้ว"
},
"instance": {
"title": "อินสแตนซ์",
"unavailable": "อินสแตนซ์นี้ไม่สามารถใช้งานได้แล้ว",
"players": "ผู้เล่น",
"capacity": "ความจุ",
"region": "ภูมิภาค",
"access": "การเข้าถึง",
"queue": "อยู่ในคิว",
"join": "เข้าร่วมใน VRChat",
"joining": "กำลังเปิด…",
"inviteMe": "เชิญตัวเอง",
"inviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat",
"worldDetails": "ดูรายละเอียดเวิลด์",
"instanceId": "รหัสอินสแตนซ์"
} }
} }
+25 -7
View File
@@ -57,13 +57,6 @@ export const developerLabels: Record<string, string> = {
moderator: "VRChat Moderator", moderator: "VRChat Moderator",
}; };
export const regionLabels: Record<string, string> = {
us: "US West",
use: "US East",
eu: "Europe",
jp: "Japan",
};
export const regionFlags: Record<string, string> = { export const regionFlags: Record<string, string> = {
us: "🇺🇸", us: "🇺🇸",
use: "🇺🇸", use: "🇺🇸",
@@ -75,6 +68,31 @@ export function regionFlag(region?: string): string | undefined {
return region ? regionFlags[region.toLowerCase()] : undefined; return region ? regionFlags[region.toLowerCase()] : undefined;
} }
type TFunc = (key: string, opts?: Record<string, unknown>) => string;
export function regionLabel(t: TFunc, region?: string): string | null {
if (!region) return null;
const key = `world:regions.${region.toLowerCase()}`;
const label = t(key);
return label === key ? region.toUpperCase() : label;
}
const createTypeToAccess: Record<string, string> = {
public: "public",
"friends+": "hidden",
friends: "friends",
invite: "private",
"invite+": "inviteRequest",
};
export function accessLabel(t: TFunc, type?: string): string {
if (!type) return t("world:instance.title");
const sdkType = createTypeToAccess[type] ?? type;
const key = `world:accessTypes.${sdkType}`;
const label = t(key);
return label === key ? type : label;
}
const languageNames: Record<string, string> = { const languageNames: Record<string, string> = {
eng: "English", eng: "English",
kor: "Korean", kor: "Korean",
-1
View File
@@ -21,7 +21,6 @@ export const useWorlds = create<WorldState>((set) => ({
upsert: (w) => set((st) => ({ worlds: { ...st.worlds, [w.id]: w } })), upsert: (w) => set((st) => ({ worlds: { ...st.worlds, [w.id]: w } })),
})); }));
let pending: World[] = []; let pending: World[] = [];
let flushScheduled = false; let flushScheduled = false;