diff --git a/src/renderer/src/components/ui/PresenceLabel.tsx b/src/renderer/src/components/ui/PresenceLabel.tsx
new file mode 100644
index 0000000..94e5e0a
--- /dev/null
+++ b/src/renderer/src/components/ui/PresenceLabel.tsx
@@ -0,0 +1,19 @@
+export function PresenceLabel({
+ label,
+ color,
+ className,
+}: {
+ label: string;
+ color: string;
+ className?: string;
+}) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/src/renderer/src/components/ui/index.ts b/src/renderer/src/components/ui/index.ts
index d24f88c..5b132f3 100644
--- a/src/renderer/src/components/ui/index.ts
+++ b/src/renderer/src/components/ui/index.ts
@@ -10,6 +10,7 @@ export { Panel } from "./Panel";
export { Tabs } from "./Tabs";
export { Stat } from "./Stat";
export { PresenceAvatar } from "./PresenceAvatar";
+export { PresenceLabel } from "./PresenceLabel";
export { CollapsibleCard } from "./CollapsibleCard";
export { Modal } from "./Modal";
export { Toggle } from "./Toggle";
diff --git a/src/renderer/src/features/friends/FriendsSidebar.tsx b/src/renderer/src/features/friends/FriendsSidebar.tsx
index 490daf0..d04ab85 100644
--- a/src/renderer/src/features/friends/FriendsSidebar.tsx
+++ b/src/renderer/src/features/friends/FriendsSidebar.tsx
@@ -1,6 +1,6 @@
import { useMemo, useState, useCallback, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
-import { isOnline, locationLabel, statusMeta } from "../../lib/vrchat";
+import { isOnline, locationLabel, presenceOf } from "../../lib/vrchat";
import { Badge, PresenceAvatar, ContextMenu } from "../../components/ui";
import { useFriends, useSelf } from "../../store/social";
import { useWorldName } from "../../store/worlds";
@@ -263,8 +263,8 @@ function FriendRow({
onContextMenu?: (e: React.MouseEvent, friend: UserProfile) => void;
}) {
const t = useT();
- const status = statusMeta[isOnline(friend) || isSelf ? friend.status : "offline"];
- const sub = friend.statusDescription || status.label;
+ const presence = presenceOf({ ...friend, isSelf });
+ const sub = friend.statusDescription || presence.effective.label;
const parsed = parseLocation(friend.location);
const worldName = useWorldName(parsed?.worldId);
const location = parsed
diff --git a/src/renderer/src/features/profile/DiscoverSection.tsx b/src/renderer/src/features/profile/DiscoverSection.tsx
index 4566b9b..21a2f84 100644
--- a/src/renderer/src/features/profile/DiscoverSection.tsx
+++ b/src/renderer/src/features/profile/DiscoverSection.tsx
@@ -5,7 +5,7 @@ import { Banner, SkeletonGrid } from "../../components/ui";
import { useT } from "../../lib/i18n";
import { api } from "../../lib/api";
import { useAsync } from "../../lib/useAsync";
-import { WorldCard } from "./WorldsSection";
+import { WorldCard } from "../world/WorldCard";
import "./profile.css";
export function DiscoverSection() {
diff --git a/src/renderer/src/features/profile/ProfileView.tsx b/src/renderer/src/features/profile/ProfileView.tsx
index a4c5f6b..7dc8bea 100644
--- a/src/renderer/src/features/profile/ProfileView.tsx
+++ b/src/renderer/src/features/profile/ProfileView.tsx
@@ -11,6 +11,7 @@ import {
Fact,
IconButton,
LinkPill,
+ PresenceLabel,
Section,
Skeleton,
Tabs,
@@ -21,9 +22,8 @@ import {
avatarOf,
bannerOf,
developerLabels,
- isOnline,
languageLabel,
- statusMeta,
+ presenceOf,
trustMeta,
} from "../../lib/vrchat";
import { useProfile } from "./useProfile";
@@ -52,13 +52,11 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
const addFriend = useAddFriend(profile.id);
const { buildItems, modal } = useUserMenu();
const trust = trustMeta[profile.trustRank];
- const online = isOnline(profile);
- const status = statusMeta[profile.status];
- const presenceStatus = online ? status : statusMeta.offline;
+ const presence = presenceOf(profile);
const statusLabel =
- !online && profile.status !== "offline"
- ? t("profile:status.offlineWas", { status: status.label })
- : status.label;
+ !presence.online && profile.status !== "offline"
+ ? t("profile:status.offlineWas", { status: presence.status.label })
+ : presence.effective.label;
const avatar = avatarOf(profile);
const banner = bannerOf(profile);
const devLabel = profile.developerType ? developerLabels[profile.developerType] : undefined;
@@ -77,7 +75,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
@@ -96,13 +94,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
{trust.label}
-
-
- {statusLabel}
-
+
{profile.pronouns ? (
{profile.pronouns}
) : null}
diff --git a/src/renderer/src/features/profile/WorldsSection.tsx b/src/renderer/src/features/profile/WorldsSection.tsx
index da28f82..ae9930c 100644
--- a/src/renderer/src/features/profile/WorldsSection.tsx
+++ b/src/renderer/src/features/profile/WorldsSection.tsx
@@ -1,10 +1,8 @@
import { useMemo, useState } from "react";
-import { Circle, Star, Users } from "lucide-react";
import type { World } from "../../../../shared/types/world";
-import { Card, CollapsibleCard, Field, HoverImage, SkeletonGrid, Tag } from "../../components/ui";
-import { compactNumber } from "../../lib/format";
+import { CollapsibleCard, Field, SkeletonGrid } from "../../components/ui";
import { useT } from "../../lib/i18n";
-import { useNav } from "../navigation/NavContext";
+import { WorldCard } from "../world/WorldCard";
import { useUserWorlds } from "./useUserWorlds";
import { useFavoriteWorlds } from "./useFavoriteWorlds";
@@ -100,95 +98,31 @@ function WorldGrid({
}) {
if (worlds.length) {
return (
-
+
{worlds.map((w) => (
))}
-
+
);
}
if (status === "loading") {
return (
-
+
-
+
);
}
if (status === "error") {
return (
-
+
{message}
-
+
);
}
return null;
}
-
-function Wrap({
- title,
- count,
- children,
-}: {
- title: string;
- count: number | string;
- children: React.ReactNode;
-}) {
- return (
-
- {children}
-
- );
-}
-
-export function WorldCard({ world }: { world: World }) {
- const t = useT();
- const { openWorld } = useNav();
- const img = world.thumbnailImageUrl || world.imageUrl;
- return (
-
openWorld(world.id)}>
-
- {img ? : null}
- {world.releaseStatus !== "public" ? (
-
- {world.releaseStatus}
-
- ) : null}
-
-
-
- {world.name}
-
-
-
- {compactNumber(world.favorites)}
-
- {world.occupants > 0 ? (
-
- {compactNumber(world.occupants)}
-
- ) : null}
- {world.visits > 0 ? (
-
- {t("profile:worlds.visitsCount", { formattedCount: compactNumber(world.visits) })}
-
- ) : null}
-
- {world.capacity}
-
-
-
-
- );
-}
diff --git a/src/renderer/src/features/search/SearchView.tsx b/src/renderer/src/features/search/SearchView.tsx
index 6496fb4..2cac459 100644
--- a/src/renderer/src/features/search/SearchView.tsx
+++ b/src/renderer/src/features/search/SearchView.tsx
@@ -1,21 +1,13 @@
import { useState, type FormEvent } from "react";
-import { Circle, Search as SearchIcon, Star, Users } from "lucide-react";
+import { Search as SearchIcon } from "lucide-react";
import type { UserProfile } from "../../../../shared/types/user";
import type { World } from "../../../../shared/types/world";
import { api } from "../../lib/api";
-import { compactNumber } from "../../lib/format";
-import {
- Avatar,
- Button,
- Card,
- ContextMenu,
- Field,
- HoverImage,
- Tabs,
- Tag,
-} from "../../components/ui";
+import { Avatar, Button, ContextMenu, Field, Tabs, Tag } from "../../components/ui";
+import { useT } from "../../lib/i18n";
import { useNav } from "../navigation/NavContext";
import { useUserMenu } from "../friends/useUserMenu";
+import { WorldCard } from "../world/WorldCard";
import { avatarOf, trustMeta } from "../../lib/vrchat";
interface UserMenu {
@@ -27,6 +19,7 @@ interface UserMenu {
type SearchTab = "users" | "worlds";
export function SearchView() {
+ const t = useT();
const { openUser, openWorld } = useNav();
const { buildItems, modal } = useUserMenu();
const [tab, setTab] = useState
("users");
@@ -57,13 +50,13 @@ export function SearchView() {
return (
- Search
+ {t("search:title")}
setQuery(e.target.value)}
autoFocus
@@ -81,17 +78,19 @@ export function SearchView() {
{results === null ? (
- Search for {tab === "users" ? "someone" : "a world"} to get started.
+ {tab === "users" ? t("search:promptUsers") : t("search:promptWorlds")}
) : results.length === 0 ? (
-
No {tab} found.
+
+ {tab === "users" ? t("search:emptyUsers") : t("search:emptyWorlds")}
+
) : tab === "users" ? (
{(results as UserProfile[]).map((u) => (
@@ -109,7 +108,7 @@ export function SearchView() {
) : (
{(results as World[]).map((w) => (
- openWorld(w.id)} />
+ openWorld(w.id)} />
))}
)}
@@ -155,39 +154,3 @@ function UserResult({
);
}
-
-function WorldResult({ world, onOpen }: { world: World; onOpen: () => void }) {
- const img = world.thumbnailImageUrl || world.imageUrl;
- return (
-
-
- {img ? : null}
-
-
-
- {world.name}
-
-
- {world.authorName}
-
-
-
- {compactNumber(world.favorites)}
-
- {world.occupants > 0 ? (
-
- {compactNumber(world.occupants)}
-
- ) : null}
-
- {world.capacity}
-
-
-
-
- );
-}
diff --git a/src/renderer/src/features/world/WorldCard.tsx b/src/renderer/src/features/world/WorldCard.tsx
new file mode 100644
index 0000000..54168ba
--- /dev/null
+++ b/src/renderer/src/features/world/WorldCard.tsx
@@ -0,0 +1,64 @@
+import { Circle, Star, Users } from "lucide-react";
+import type { World } from "../../../../shared/types/world";
+import { Card, HoverImage, Tag } from "../../components/ui";
+import { compactNumber } from "../../lib/format";
+import { useT } from "../../lib/i18n";
+import { useNav } from "../navigation/NavContext";
+
+export function WorldCard({
+ world,
+ showAuthor,
+ onOpen,
+}: {
+ world: World;
+ showAuthor?: boolean;
+ onOpen?: () => void;
+}) {
+ const t = useT();
+ const { openWorld } = useNav();
+ const img = world.thumbnailImageUrl || world.imageUrl;
+ return (
+
openWorld(world.id))}>
+
+ {img ? : null}
+ {world.releaseStatus !== "public" ? (
+
+ {world.releaseStatus}
+
+ ) : null}
+
+
+
+ {world.name}
+
+ {showAuthor ? (
+
+ {world.authorName}
+
+ ) : null}
+
+
+ {compactNumber(world.favorites)}
+
+ {world.occupants > 0 ? (
+
+ {compactNumber(world.occupants)}
+
+ ) : null}
+ {world.visits > 0 ? (
+
+ {t("profile:worlds.visitsCount", { formattedCount: compactNumber(world.visits) })}
+
+ ) : null}
+
+ {world.capacity}
+
+
+
+
+ );
+}
diff --git a/src/renderer/src/lib/i18n/locales/en/search.json b/src/renderer/src/lib/i18n/locales/en/search.json
new file mode 100644
index 0000000..a4d666e
--- /dev/null
+++ b/src/renderer/src/lib/i18n/locales/en/search.json
@@ -0,0 +1,18 @@
+{
+ "title": "Search",
+ "tabs": {
+ "users": "Users",
+ "worlds": "Worlds"
+ },
+ "field": {
+ "labelUsers": "Find a user",
+ "labelWorlds": "Find a world",
+ "placeholderUsers": "Search users by name…",
+ "placeholderWorlds": "Search worlds by name…"
+ },
+ "button": "Search",
+ "promptUsers": "Search for someone to get started.",
+ "promptWorlds": "Search for a world to get started.",
+ "emptyUsers": "No users found.",
+ "emptyWorlds": "No worlds found."
+}
diff --git a/src/renderer/src/lib/i18n/locales/ja/search.json b/src/renderer/src/lib/i18n/locales/ja/search.json
new file mode 100644
index 0000000..d379287
--- /dev/null
+++ b/src/renderer/src/lib/i18n/locales/ja/search.json
@@ -0,0 +1,18 @@
+{
+ "title": "検索",
+ "tabs": {
+ "users": "ユーザー",
+ "worlds": "ワールド"
+ },
+ "field": {
+ "labelUsers": "ユーザーを探す",
+ "labelWorlds": "ワールドを探す",
+ "placeholderUsers": "名前でユーザーを検索…",
+ "placeholderWorlds": "名前でワールドを検索…"
+ },
+ "button": "検索",
+ "promptUsers": "ユーザーを検索して始めましょう。",
+ "promptWorlds": "ワールドを検索して始めましょう。",
+ "emptyUsers": "ユーザーが見つかりません。",
+ "emptyWorlds": "ワールドが見つかりません。"
+}
diff --git a/src/renderer/src/lib/i18n/locales/th/search.json b/src/renderer/src/lib/i18n/locales/th/search.json
new file mode 100644
index 0000000..1f179b8
--- /dev/null
+++ b/src/renderer/src/lib/i18n/locales/th/search.json
@@ -0,0 +1,18 @@
+{
+ "title": "ค้นหา",
+ "tabs": {
+ "users": "ผู้ใช้",
+ "worlds": "เวิลด์"
+ },
+ "field": {
+ "labelUsers": "ค้นหาผู้ใช้",
+ "labelWorlds": "ค้นหาเวิลด์",
+ "placeholderUsers": "ค้นหาผู้ใช้ตามชื่อ…",
+ "placeholderWorlds": "ค้นหาเวิลด์ตามชื่อ…"
+ },
+ "button": "ค้นหา",
+ "promptUsers": "ค้นหาใครสักคนเพื่อเริ่มต้น",
+ "promptWorlds": "ค้นหาเวิลด์เพื่อเริ่มต้น",
+ "emptyUsers": "ไม่พบผู้ใช้",
+ "emptyWorlds": "ไม่พบเวิลด์"
+}
diff --git a/src/renderer/src/lib/vrchat.ts b/src/renderer/src/lib/vrchat.ts
index 3c5f963..786c89b 100644
--- a/src/renderer/src/lib/vrchat.ts
+++ b/src/renderer/src/lib/vrchat.ts
@@ -36,6 +36,25 @@ export const statusMeta: Record
=
offline: { label: "Offline", color: "var(--status-offline)" },
};
+interface Presence {
+ online: boolean;
+ color: string;
+ status: { label: string; color: string };
+ effective: { label: string; color: string };
+}
+
+export function presenceOf(u: {
+ status: UserStatus;
+ state?: "online" | "active" | "offline";
+ location?: string;
+ isSelf?: boolean;
+}): Presence {
+ const online = isOnline(u);
+ const status = statusMeta[u.status];
+ const effective = online ? status : statusMeta.offline;
+ return { online, color: effective.color, status, effective };
+}
+
export function avatarOf(p: {
userIcon: string;
currentAvatarImageUrl: string;