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 { ProfileView } from "../features/profile/ProfileView";
import { WorldView } from "../features/world/WorldView";
import { InstanceView } from "../features/world/InstanceView";
import { GroupView } from "../features/group/GroupView";
import { AccountSettingsView } from "../features/account/AccountSettingsView";
import { SearchView } from "../features/search/SearchView";
@@ -164,6 +165,8 @@ function Shell() {
<div key={stageKey} className="stage__inner animate-rise">
{nav.current.kind === "world" ? (
<WorldView worldId={nav.current.id} />
) : nav.current.kind === "instance" ? (
<InstanceView worldId={nav.current.worldId} instanceId={nav.current.instanceId} />
) : nav.current.kind === "group" ? (
<GroupView groupId={nav.current.id} />
) : nav.current.kind === "account" ? (
@@ -3,6 +3,7 @@ import { createContext, useContext, useMemo, useState } from "react";
export type View =
| { kind: "user"; id: "me" | string }
| { kind: "world"; id: string }
| { kind: "instance"; worldId: string; instanceId: string; location: string }
| { kind: "group"; id: string }
| { kind: "account" }
| { kind: "settings" }
@@ -15,6 +16,7 @@ interface Nav {
canBack: boolean;
openUser: (id: "me" | string) => void;
openWorld: (id: string) => void;
openInstance: (worldId: string, instanceId: string, location: string) => void;
openGroup: (id: string) => void;
openAccount: () => void;
openSettings: () => void;
@@ -44,6 +46,8 @@ export function NavProvider({ children }: { children: React.ReactNode }) {
canBack: stack.length > 1,
openUser: (id) => push({ kind: "user", id }),
openWorld: (id) => push({ kind: "world", id }),
openInstance: (worldId, instanceId, location) =>
push({ kind: "instance", worldId, instanceId, location }),
openGroup: (id) => push({ kind: "group", id }),
openAccount: () => root({ kind: "account" }),
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 === "user" && b.kind === "user") 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;
return true;
}
@@ -1,6 +1,7 @@
import { Star, Users } from "lucide-react";
import type { Group } from "../../../../shared/types/group";
import { Avatar, CollapsibleCard, SkeletonGrid } from "../../components/ui";
import { useT } from "../../lib/i18n";
import { useNav } from "../navigation/NavContext";
import { useUserGroups } from "./useUserGroups";
@@ -41,6 +42,7 @@ export function GroupsSection({ userId }: { userId: string }) {
}
function FeaturedGroup({ group }: { group: Group }) {
const t = useT();
const { openGroup } = useNav();
return (
<button
@@ -62,7 +64,7 @@ function FeaturedGroup({ group }: { group: Group }) {
<Avatar src={group.iconUrl} name={group.name} size={52} className="!rounded-xl" />
<div className="min-w-0 flex-1">
<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 className="mt-0.5 truncate text-[15px] font-semibold" title={group.name}>
{group.name}
@@ -106,8 +108,9 @@ function GroupMeta({ group }: { group: Group }) {
}
function Wrap({ count, children }: { count: number | string; children: React.ReactNode }) {
const t = useT();
return (
<CollapsibleCard title="Groups" count={count}>
<CollapsibleCard title={t("profile:groups.title")} count={count}>
{children}
</CollapsibleCard>
);
@@ -5,10 +5,12 @@ import { useWorld } from "../../store/worlds";
import { useNav } from "../navigation/NavContext";
import { api } from "../../lib/api";
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 }) {
const { openWorld } = useNav();
const t = useT();
const { openInstance } = useNav();
const parsed = parseLocation(location);
const world = useWorld(parsed?.worldId);
const instance = useAsync(
@@ -33,14 +35,12 @@ export function LocationSection({ location }: { location?: Location }) {
if (parsed && world) {
const img = world.thumbnailImageUrl || world.imageUrl;
const region = parsed.region
? (regionLabels[parsed.region] ?? parsed.region.toUpperCase())
: null;
const region = regionLabel(t, parsed.region);
const flag = regionFlag(parsed.region);
return (
<Wrap>
<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"
>
<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}
</div>
<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="inline-flex items-center gap-1" title="Instance">
<span className="truncate">
{t("profile:location.by", { author: world.authorName })}
</span>
<span
className="inline-flex items-center gap-1"
title={t("profile:location.instance")}
>
<Hash size={11} />
{parsed.instanceId}
</span>
@@ -71,12 +76,13 @@ export function LocationSection({ location }: { location?: Location }) {
className="inline-flex items-center gap-1"
style={{ color: "var(--status-active)" }}
>
<Users size={11} /> {inInstance.userCount} in instance
<Users size={11} />{" "}
{t("profile:location.inInstance", { n: inInstance.userCount })}
</span>
) : null}
{world.occupants > 0 ? (
<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>
) : null}
</div>
@@ -103,10 +109,11 @@ export function LocationSection({ location }: { location?: Location }) {
}
function Wrap({ children }: { children: React.ReactNode }) {
const t = useT();
return (
<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">
<MapPin size={12} /> Currently in
<MapPin size={12} /> {t("profile:location.title")}
</h3>
{children}
</section>
@@ -56,7 +56,9 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
const status = statusMeta[profile.status];
const presenceStatus = online ? status : statusMeta.offline;
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 banner = bannerOf(profile);
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="flex flex-wrap items-center gap-2.5">
<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 ? (
<Tag color="var(--status-join)">Friend</Tag>
<Tag color="var(--status-join)">{t("profile:badge.friend")}</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 className="mt-2.5 flex flex-wrap items-center gap-3">
<Tag color={trust.color}>{trust.label}</Tag>
@@ -142,10 +146,10 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
<Tabs
tabs={[
{ id: "overview", label: "Overview" },
{ id: "worlds", label: "Worlds" },
{ id: "favorites", label: "Favorite Worlds" },
{ id: "groups", label: "Groups" },
{ id: "overview", label: t("profile:tabs.overview") },
{ id: "worlds", label: t("profile:tabs.worlds") },
{ id: "favorites", label: t("profile:tabs.favorites") },
{ id: "groups", label: t("profile:tabs.groups") },
]}
active={tab}
onChange={setTab}
@@ -154,7 +158,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
{tab === "overview" ? (
<div className="flex flex-col gap-5 rise-in">
{profile.note ? (
<Section title="Your note">
<Section title={t("profile:sections.note")}>
<p className="text-[14px] leading-relaxed whitespace-pre-wrap text-text">
{profile.note}
</p>
@@ -162,7 +166,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null}
{profile.statusDescription || profile.bio || bioLinks.length ? (
<Section title="About">
<Section title={t("profile:sections.about")}>
{profile.statusDescription ? (
<p className="text-[15px] italic text-text">{profile.statusDescription}</p>
) : null}
@@ -184,27 +188,41 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null}
<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">
{profile.dateJoined ? (
<Fact label="Joined" value={formatDate(profile.dateJoined)} />
<Fact
label={t("profile:facts.joined")}
value={formatDate(profile.dateJoined)}
/>
) : null}
{profile.lastLogin ? (
<Fact label="Last login" value={formatDateTime(profile.lastLogin)} />
<Fact
label={t("profile:facts.lastLogin")}
value={formatDateTime(profile.lastLogin)}
/>
) : null}
{profile.lastActivity ? (
<Fact label="Last activity" value={formatDateTime(profile.lastActivity)} />
<Fact
label={t("profile:facts.lastActivity")}
value={formatDateTime(profile.lastActivity)}
/>
) : null}
{profile.lastPlatform ? (
<Fact label="Platform" value={platformLabel(profile.lastPlatform)} />
<Fact
label={t("profile:facts.platform")}
value={platformLabel(profile.lastPlatform)}
/>
) : null}
{profile.state ? <Fact label="State" value={stateLabel(profile.state)} /> : null}
<Fact label="User ID" value={profile.id} mono />
{profile.state ? (
<Fact label={t("profile:facts.state")} value={stateLabel(profile.state, t)} />
) : null}
<Fact label={t("profile:facts.userId")} value={profile.id} mono />
</dl>
</Section>
{profile.languages?.length ? (
<Section title="Languages">
<Section title={t("profile:sections.languages")}>
<div className="flex flex-wrap gap-1.5">
{profile.languages.map((code) => (
<Tag key={code}>{languageLabel(code)}</Tag>
@@ -214,7 +232,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null}
{profile.currentAvatarTags?.length ? (
<Section title="Avatar tags">
<Section title={t("profile:sections.avatarTags")}>
<div className="flex flex-wrap gap-1.5">
{profile.currentAvatarTags.map((t) => (
<Tag key={t}>{prettyTag(t, "content_")}</Tag>
@@ -224,7 +242,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
) : null}
{profile.badges?.length ? (
<Section title="Badges">
<Section title={t("profile:sections.badges")}>
<div className="flex flex-wrap gap-2.5">
{[...profile.badges]
.sort((a, b) => Number(b.showcased) - Number(a.showcased))
@@ -250,7 +268,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
</div>
{profile.pastDisplayNames?.length ? (
<Section title="Former names" collapsible>
<Section title={t("profile:sections.formerNames")} collapsible>
<div className="flex flex-wrap gap-2">
{profile.pastDisplayNames.map((p) => (
<span
@@ -261,7 +279,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
{p.updatedAt ? (
<em className="text-xs not-italic text-faint">
{" "}
· until {formatDate(p.updatedAt)}
· {t("profile:formerName.until", { date: formatDate(p.updatedAt) })}
</em>
) : null}
</span>
@@ -315,6 +333,7 @@ interface AddFriend {
}
function useAddFriend(userId: string): AddFriend {
const t = useT();
const [state, setState] = useState<AddFriendState>("idle");
const [error, setError] = useState<string>();
@@ -325,10 +344,10 @@ function useAddFriend(userId: string): AddFriend {
.add(userId)
.then(() => setState("sent"))
.catch((err) => {
setError(errorMessage(err, "Couldn't send friend request."));
setError(errorMessage(err, t("profile:addFriendError")));
setState("idle");
});
}, [userId]);
}, [userId, t]);
return { state, error, send };
}
@@ -386,8 +405,8 @@ function prettyLink(url: string): string {
}
}
function stateLabel(state: NonNullable<UserProfile["state"]>): string {
if (state === "online") return "In VRChat";
if (state === "active") return "On website / mobile";
return "Offline";
function stateLabel(state: NonNullable<UserProfile["state"]>, t: ReturnType<typeof useT>): string {
if (state === "online") return t("profile:state.online");
if (state === "active") return t("profile:state.active");
return t("profile:state.offline");
}
@@ -2,22 +2,32 @@ import { Circle, Star, Users } from "lucide-react";
import type { World } from "../../../../shared/types/world";
import { Card, CollapsibleCard, 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 }) {
const t = useT();
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 }) {
const t = useT();
const { status, folders } = useFavoriteWorlds(userId);
const loading = status === "loading";
if (loading && !folders.length) {
return (
<CollapsibleCard title="Favorite worlds" count="…">
<CollapsibleCard title={t("profile:worlds.favorites")} count="…">
<SkeletonGrid count={3} />
</CollapsibleCard>
);
@@ -100,6 +110,7 @@ function Wrap({
}
function WorldCard({ world }: { world: World }) {
const t = useT();
const { openWorld } = useNav();
const img = world.thumbnailImageUrl || world.imageUrl;
return (
@@ -117,22 +128,27 @@ function WorldCard({ world }: { world: World }) {
{world.name}
</div>
<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)}
</span>
{world.occupants > 0 ? (
<span
className="inline-flex items-center gap-1"
title="players online now"
title={t("profile:worlds.tip.online")}
style={{ color: "var(--status-active)" }}
>
<Circle size={9} fill="currentColor" /> {compactNumber(world.occupants)}
</span>
) : null}
{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}
<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}
</span>
</div>
@@ -25,7 +25,7 @@ import type { AppConfig, PreferredRegion, RegionPing } from "../../../../shared/
import type { UnityStatus } from "../../../../shared/types/unity";
import { api, errorMessage } from "../../lib/api";
import { useAsync } from "../../lib/useAsync";
import { regionFlag, regionLabels } from "../../lib/vrchat";
import { regionFlag, regionLabel } from "../../lib/vrchat";
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";
@@ -420,9 +420,7 @@ function RegionSection() {
.reduce<(RegionPing & { ms: number }) | null>((a, b) => (!a || b.ms < a.ms ? b : a), null);
const label = (r: PreferredRegion) =>
r === "auto"
? t("settings:region.auto")
: `${regionFlag(r) ?? ""} ${regionLabels[r] ?? r.toUpperCase()}`.trim();
r === "auto" ? t("settings:region.auto") : `${regionFlag(r) ?? ""} ${regionLabel(t, r)}`.trim();
return (
<Section
@@ -463,7 +461,7 @@ function RegionSection() {
{pings.map((p) => (
<div key={p.region} className="flex items-center gap-2 text-[13px]">
<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>
{p.ms === null ? (
<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 { api, errorMessage } from "../../lib/api";
import { useCopied } from "../../lib/useCopied";
import { regionFlag } from "../../lib/vrchat";
import { accessLabel, regionFlag, regionLabel } from "../../lib/vrchat";
import { useT } from "../../lib/i18n";
import type {
CreateInstanceInput,
@@ -116,14 +116,14 @@ export function CreateInstanceModal({
value={type}
options={TYPES}
onChange={setType}
render={(v) => t(`world:create.types.${v}`)}
render={(v) => accessLabel(t, v)}
/>
<Picker
label={t("world:create.region")}
value={region}
options={REGIONS}
onChange={pickRegion}
render={(v) => `${regionFlag(v) ?? ""} ${t(`world:create.regions.${v}`)}`.trim()}
render={(v) => `${regionFlag(v) ?? ""} ${regionLabel(t, v)}`.trim()}
hint={
detecting ? (
<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">
<Sparkles size={11} />
{t("world:create.autoDetected", {
region: t(`world:create.regions.${region}`),
region: regionLabel(t, region),
})}
</span>
) : 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",
"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": {
"title": "Create Instance",
"type": "Access",
@@ -8,19 +24,6 @@
"creating": "Creating…",
"detecting": "Detecting best 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.",
"launch": "Launch VRChat",
"launching": "Launching…",
@@ -32,5 +35,20 @@
"unlockedHint": "Bypasses the instance rules. Anyone with it can join. Not recommended.",
"copy": "Copy",
"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": "インスタンスを作成",
"regions": {
"us": "米国西部",
"use": "米国東部",
"eu": "ヨーロッパ",
"jp": "日本"
},
"accessTypes": {
"public": "パブリック",
"hidden": "フレンド+",
"friends": "フレンド",
"private": "インバイト",
"inviteRequest": "インバイト+",
"group": "グループ",
"groupPlus": "グループ+",
"groupPublic": "グループパブリック"
},
"create": {
"title": "インスタンスを作成",
"type": "アクセス",
@@ -8,19 +24,6 @@
"creating": "作成中…",
"detecting": "最適なリージョンを検出中…",
"autoDetected": "自動: {{region}}",
"types": {
"public": "パブリック",
"friends+": "フレンド+",
"friends": "フレンド",
"invite": "インバイト",
"invite+": "インバイト+"
},
"regions": {
"us": "米国西部",
"use": "米国東部",
"eu": "ヨーロッパ",
"jp": "日本"
},
"ready": "インスタンスを作成しました。",
"launch": "VRChatを起動",
"launching": "起動中…",
@@ -32,5 +35,20 @@
"unlockedHint": "インスタンスのルールを無視します。リンクを持つ誰でも参加できます。推奨しません。",
"copy": "コピー",
"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": "สร้างอินสแตนซ์",
"regions": {
"us": "สหรัฐฯ ฝั่งตะวันตก",
"use": "สหรัฐฯ ฝั่งตะวันออก",
"eu": "ยุโรป",
"jp": "ญี่ปุ่น"
},
"accessTypes": {
"public": "สาธารณะ",
"hidden": "เพื่อน+",
"friends": "เพื่อน",
"private": "เชิญ",
"inviteRequest": "เชิญ+",
"group": "กลุ่ม",
"groupPlus": "กลุ่ม+",
"groupPublic": "กลุ่มสาธารณะ"
},
"create": {
"title": "สร้างอินสแตนซ์",
"type": "การเข้าถึง",
@@ -8,19 +24,6 @@
"creating": "กำลังสร้าง…",
"detecting": "กำลังตรวจหาภูมิภาคที่ดีที่สุด…",
"autoDetected": "อัตโนมัติ: {{region}}",
"types": {
"public": "สาธารณะ",
"friends+": "เพื่อน+",
"friends": "เพื่อน",
"invite": "เชิญ",
"invite+": "เชิญ+"
},
"regions": {
"us": "สหรัฐฯ ฝั่งตะวันตก",
"use": "สหรัฐฯ ฝั่งตะวันออก",
"eu": "ยุโรป",
"jp": "ญี่ปุ่น"
},
"ready": "สร้างอินสแตนซ์แล้ว",
"launch": "เปิด VRChat",
"launching": "กำลังเปิด…",
@@ -32,5 +35,20 @@
"unlockedHint": "ข้ามกฎของอินสแตนซ์ ใครก็ตามที่มีลิงก์สามารถเข้าร่วมได้ ไม่แนะนำ",
"copy": "คัดลอก",
"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",
};
export const regionLabels: Record<string, string> = {
us: "US West",
use: "US East",
eu: "Europe",
jp: "Japan",
};
export const regionFlags: Record<string, string> = {
us: "🇺🇸",
use: "🇺🇸",
@@ -75,6 +68,31 @@ export function regionFlag(region?: string): string | 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> = {
eng: "English",
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 } })),
}));
let pending: World[] = [];
let flushScheduled = false;