mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +00:00
✨ feat: Add instance view
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}}人"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}} คนในเวิลด์"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "รหัสอินสแตนซ์"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user