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;