♻️ refactor: WorldCard component, PresenceLabel, etc

This commit is contained in:
2026-06-29 02:40:22 +07:00
parent 99ac7761ce
commit e1c03f9deb
12 changed files with 197 additions and 151 deletions
@@ -0,0 +1,19 @@
export function PresenceLabel({
label,
color,
className,
}: {
label: string;
color: string;
className?: string;
}) {
return (
<span
className={`inline-flex items-center gap-1.5 text-[13px] font-semibold ${className ?? ""}`}
style={{ color }}
>
<span className="size-2 rounded-full" style={{ background: color }} />
{label}
</span>
);
}
+1
View File
@@ -10,6 +10,7 @@ export { Panel } from "./Panel";
export { Tabs } from "./Tabs"; export { Tabs } from "./Tabs";
export { Stat } from "./Stat"; export { Stat } from "./Stat";
export { PresenceAvatar } from "./PresenceAvatar"; export { PresenceAvatar } from "./PresenceAvatar";
export { PresenceLabel } from "./PresenceLabel";
export { CollapsibleCard } from "./CollapsibleCard"; export { CollapsibleCard } from "./CollapsibleCard";
export { Modal } from "./Modal"; export { Modal } from "./Modal";
export { Toggle } from "./Toggle"; export { Toggle } from "./Toggle";
@@ -1,6 +1,6 @@
import { useMemo, useState, useCallback, type ReactNode } from "react"; import { useMemo, useState, useCallback, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-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 { Badge, PresenceAvatar, ContextMenu } from "../../components/ui";
import { useFriends, useSelf } from "../../store/social"; import { useFriends, useSelf } from "../../store/social";
import { useWorldName } from "../../store/worlds"; import { useWorldName } from "../../store/worlds";
@@ -263,8 +263,8 @@ function FriendRow({
onContextMenu?: (e: React.MouseEvent, friend: UserProfile) => void; onContextMenu?: (e: React.MouseEvent, friend: UserProfile) => void;
}) { }) {
const t = useT(); const t = useT();
const status = statusMeta[isOnline(friend) || isSelf ? friend.status : "offline"]; const presence = presenceOf({ ...friend, isSelf });
const sub = friend.statusDescription || status.label; const sub = friend.statusDescription || presence.effective.label;
const parsed = parseLocation(friend.location); const parsed = parseLocation(friend.location);
const worldName = useWorldName(parsed?.worldId); const worldName = useWorldName(parsed?.worldId);
const location = parsed const location = parsed
@@ -5,7 +5,7 @@ import { Banner, SkeletonGrid } from "../../components/ui";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { useAsync } from "../../lib/useAsync"; import { useAsync } from "../../lib/useAsync";
import { WorldCard } from "./WorldsSection"; import { WorldCard } from "../world/WorldCard";
import "./profile.css"; import "./profile.css";
export function DiscoverSection() { export function DiscoverSection() {
@@ -11,6 +11,7 @@ import {
Fact, Fact,
IconButton, IconButton,
LinkPill, LinkPill,
PresenceLabel,
Section, Section,
Skeleton, Skeleton,
Tabs, Tabs,
@@ -21,9 +22,8 @@ import {
avatarOf, avatarOf,
bannerOf, bannerOf,
developerLabels, developerLabels,
isOnline,
languageLabel, languageLabel,
statusMeta, presenceOf,
trustMeta, trustMeta,
} from "../../lib/vrchat"; } from "../../lib/vrchat";
import { useProfile } from "./useProfile"; import { useProfile } from "./useProfile";
@@ -52,13 +52,11 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
const addFriend = useAddFriend(profile.id); const addFriend = useAddFriend(profile.id);
const { buildItems, modal } = useUserMenu(); const { buildItems, modal } = useUserMenu();
const trust = trustMeta[profile.trustRank]; const trust = trustMeta[profile.trustRank];
const online = isOnline(profile); const presence = presenceOf(profile);
const status = statusMeta[profile.status];
const presenceStatus = online ? status : statusMeta.offline;
const statusLabel = const statusLabel =
!online && profile.status !== "offline" !presence.online && profile.status !== "offline"
? t("profile:status.offlineWas", { status: status.label }) ? t("profile:status.offlineWas", { status: presence.status.label })
: status.label; : presence.effective.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;
@@ -77,7 +75,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
<Avatar src={avatar} name={profile.displayName} size={116} /> <Avatar src={avatar} name={profile.displayName} size={116} />
<span <span
className="profile__status-dot" className="profile__status-dot"
style={{ background: presenceStatus.color }} style={{ background: presence.color }}
title={statusLabel} title={statusLabel}
/> />
</div> </div>
@@ -96,13 +94,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
</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>
<span <PresenceLabel label={statusLabel} color={presence.color} />
className="inline-flex items-center gap-1.5 text-[13px] font-semibold"
style={{ color: presenceStatus.color }}
>
<span className="size-2 rounded-full" style={{ background: presenceStatus.color }} />
{statusLabel}
</span>
{profile.pronouns ? ( {profile.pronouns ? (
<span className="text-[13px] text-muted">{profile.pronouns}</span> <span className="text-[13px] text-muted">{profile.pronouns}</span>
) : null} ) : null}
@@ -1,10 +1,8 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Circle, Star, Users } from "lucide-react";
import type { World } from "../../../../shared/types/world"; import type { World } from "../../../../shared/types/world";
import { Card, CollapsibleCard, Field, HoverImage, SkeletonGrid, Tag } from "../../components/ui"; import { CollapsibleCard, Field, SkeletonGrid } from "../../components/ui";
import { compactNumber } from "../../lib/format";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { useNav } from "../navigation/NavContext"; import { WorldCard } from "../world/WorldCard";
import { useUserWorlds } from "./useUserWorlds"; import { useUserWorlds } from "./useUserWorlds";
import { useFavoriteWorlds } from "./useFavoriteWorlds"; import { useFavoriteWorlds } from "./useFavoriteWorlds";
@@ -100,95 +98,31 @@ function WorldGrid({
}) { }) {
if (worlds.length) { if (worlds.length) {
return ( return (
<Wrap title={title} count={worlds.length}> <CollapsibleCard title={title} count={worlds.length}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{worlds.map((w) => ( {worlds.map((w) => (
<WorldCard key={w.id} world={w} /> <WorldCard key={w.id} world={w} />
))} ))}
</div> </div>
</Wrap> </CollapsibleCard>
); );
} }
if (status === "loading") { if (status === "loading") {
return ( return (
<Wrap title={title} count="…"> <CollapsibleCard title={title} count="…">
<SkeletonGrid count={3} /> <SkeletonGrid count={3} />
</Wrap> </CollapsibleCard>
); );
} }
if (status === "error") { if (status === "error") {
return ( return (
<Wrap title={title} count="!"> <CollapsibleCard title={title} count="!">
<p className="text-[13px] text-faint">{message}</p> <p className="text-[13px] text-faint">{message}</p>
</Wrap> </CollapsibleCard>
); );
} }
return null; return null;
} }
function Wrap({
title,
count,
children,
}: {
title: string;
count: number | string;
children: React.ReactNode;
}) {
return (
<CollapsibleCard title={title} count={count}>
{children}
</CollapsibleCard>
);
}
export function WorldCard({ world }: { world: World }) {
const t = useT();
const { openWorld } = useNav();
const img = world.thumbnailImageUrl || world.imageUrl;
return (
<Card onClick={() => openWorld(world.id)}>
<div className="relative aspect-video bg-surface-hover">
{img ? <HoverImage src={img} loading="lazy" /> : null}
{world.releaseStatus !== "public" ? (
<span className="absolute right-1.5 top-1.5">
<Tag color="var(--status-ask)">{world.releaseStatus}</Tag>
</span>
) : null}
</div>
<div className="p-2.5">
<div className="truncate text-[13px] font-semibold" title={world.name}>
{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={t("profile:worlds.tip.favorites")}
>
<Star size={12} /> {compactNumber(world.favorites)}
</span>
{world.occupants > 0 ? (
<span
className="inline-flex items-center gap-1"
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={t("profile:worlds.tip.visits")}>
{t("profile:worlds.visitsCount", { formattedCount: compactNumber(world.visits) })}
</span>
) : null}
<span className="inline-flex items-center gap-1" title={t("profile:worlds.tip.capacity")}>
<Users size={12} /> {world.capacity}
</span>
</div>
</div>
</Card>
);
}
+20 -57
View File
@@ -1,21 +1,13 @@
import { useState, type FormEvent } from "react"; 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 { UserProfile } from "../../../../shared/types/user";
import type { World } from "../../../../shared/types/world"; import type { World } from "../../../../shared/types/world";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { compactNumber } from "../../lib/format"; import { Avatar, Button, ContextMenu, Field, Tabs, Tag } from "../../components/ui";
import { import { useT } from "../../lib/i18n";
Avatar,
Button,
Card,
ContextMenu,
Field,
HoverImage,
Tabs,
Tag,
} from "../../components/ui";
import { useNav } from "../navigation/NavContext"; import { useNav } from "../navigation/NavContext";
import { useUserMenu } from "../friends/useUserMenu"; import { useUserMenu } from "../friends/useUserMenu";
import { WorldCard } from "../world/WorldCard";
import { avatarOf, trustMeta } from "../../lib/vrchat"; import { avatarOf, trustMeta } from "../../lib/vrchat";
interface UserMenu { interface UserMenu {
@@ -27,6 +19,7 @@ interface UserMenu {
type SearchTab = "users" | "worlds"; type SearchTab = "users" | "worlds";
export function SearchView() { export function SearchView() {
const t = useT();
const { openUser, openWorld } = useNav(); const { openUser, openWorld } = useNav();
const { buildItems, modal } = useUserMenu(); const { buildItems, modal } = useUserMenu();
const [tab, setTab] = useState<SearchTab>("users"); const [tab, setTab] = useState<SearchTab>("users");
@@ -57,13 +50,13 @@ export function SearchView() {
return ( return (
<div className="mx-auto w-full max-w-[760px] px-12 py-10"> <div className="mx-auto w-full max-w-[760px] px-12 py-10">
<header className="mb-5"> <header className="mb-5">
<h1 className="text-[26px] font-bold tracking-[-0.4px]">Search</h1> <h1 className="text-[26px] font-bold tracking-[-0.4px]">{t("search:title")}</h1>
</header> </header>
<Tabs <Tabs
tabs={[ tabs={[
{ id: "users", label: "Users" }, { id: "users", label: t("search:tabs.users") },
{ id: "worlds", label: "Worlds" }, { id: "worlds", label: t("search:tabs.worlds") },
]} ]}
active={tab} active={tab}
onChange={setTab} onChange={setTab}
@@ -72,8 +65,12 @@ export function SearchView() {
<form onSubmit={submit} className="mt-5 flex items-end gap-2.5"> <form onSubmit={submit} className="mt-5 flex items-end gap-2.5">
<div className="flex-1"> <div className="flex-1">
<Field <Field
label={tab === "users" ? "Find a user" : "Find a world"} label={tab === "users" ? t("search:field.labelUsers") : t("search:field.labelWorlds")}
placeholder={tab === "users" ? "Search users by name…" : "Search worlds by name…"} placeholder={
tab === "users"
? t("search:field.placeholderUsers")
: t("search:field.placeholderWorlds")
}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
autoFocus autoFocus
@@ -81,17 +78,19 @@ export function SearchView() {
</div> </div>
<Button type="submit" loading={busy} disabled={query.trim().length < 2}> <Button type="submit" loading={busy} disabled={query.trim().length < 2}>
<SearchIcon size={15} /> <SearchIcon size={15} />
Search {t("search:button")}
</Button> </Button>
</form> </form>
<div className="mt-6"> <div className="mt-6">
{results === null ? ( {results === null ? (
<p className="text-[13.5px] text-faint"> <p className="text-[13.5px] text-faint">
Search for {tab === "users" ? "someone" : "a world"} to get started. {tab === "users" ? t("search:promptUsers") : t("search:promptWorlds")}
</p> </p>
) : results.length === 0 ? ( ) : results.length === 0 ? (
<p className="text-[13.5px] text-faint">No {tab} found.</p> <p className="text-[13.5px] text-faint">
{tab === "users" ? t("search:emptyUsers") : t("search:emptyWorlds")}
</p>
) : tab === "users" ? ( ) : tab === "users" ? (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{(results as UserProfile[]).map((u) => ( {(results as UserProfile[]).map((u) => (
@@ -109,7 +108,7 @@ export function SearchView() {
) : ( ) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{(results as World[]).map((w) => ( {(results as World[]).map((w) => (
<WorldResult key={w.id} world={w} onOpen={() => openWorld(w.id)} /> <WorldCard key={w.id} world={w} showAuthor onOpen={() => openWorld(w.id)} />
))} ))}
</div> </div>
)} )}
@@ -155,39 +154,3 @@ function UserResult({
</button> </button>
); );
} }
function WorldResult({ world, onOpen }: { world: World; onOpen: () => void }) {
const img = world.thumbnailImageUrl || world.imageUrl;
return (
<Card onClick={onOpen}>
<div className="relative aspect-video bg-surface-hover">
{img ? <HoverImage src={img} loading="lazy" /> : null}
</div>
<div className="p-2.5">
<div className="truncate text-[13px] font-semibold" title={world.name}>
{world.name}
</div>
<div className="truncate text-[11px] text-faint" title={world.authorName}>
{world.authorName}
</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">
<Star size={12} /> {compactNumber(world.favorites)}
</span>
{world.occupants > 0 ? (
<span
className="inline-flex items-center gap-1"
title="players online now"
style={{ color: "var(--status-active)" }}
>
<Circle size={9} fill="currentColor" /> {compactNumber(world.occupants)}
</span>
) : null}
<span className="inline-flex items-center gap-1" title="capacity">
<Users size={12} /> {world.capacity}
</span>
</div>
</div>
</Card>
);
}
@@ -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 (
<Card onClick={onOpen ?? (() => openWorld(world.id))}>
<div className="relative aspect-video bg-surface-hover">
{img ? <HoverImage src={img} loading="lazy" /> : null}
{world.releaseStatus !== "public" ? (
<span className="absolute right-1.5 top-1.5">
<Tag color="var(--status-ask)">{world.releaseStatus}</Tag>
</span>
) : null}
</div>
<div className="p-2.5">
<div className="truncate text-[13px] font-semibold" title={world.name}>
{world.name}
</div>
{showAuthor ? (
<div className="truncate text-[11px] text-faint" title={world.authorName}>
{world.authorName}
</div>
) : null}
<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={t("profile:worlds.tip.favorites")}>
<Star size={12} /> {compactNumber(world.favorites)}
</span>
{world.occupants > 0 ? (
<span
className="inline-flex items-center gap-1"
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={t("profile:worlds.tip.visits")}>
{t("profile:worlds.visitsCount", { formattedCount: compactNumber(world.visits) })}
</span>
) : null}
<span className="inline-flex items-center gap-1" title={t("profile:worlds.tip.capacity")}>
<Users size={12} /> {world.capacity}
</span>
</div>
</div>
</Card>
);
}
@@ -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."
}
@@ -0,0 +1,18 @@
{
"title": "検索",
"tabs": {
"users": "ユーザー",
"worlds": "ワールド"
},
"field": {
"labelUsers": "ユーザーを探す",
"labelWorlds": "ワールドを探す",
"placeholderUsers": "名前でユーザーを検索…",
"placeholderWorlds": "名前でワールドを検索…"
},
"button": "検索",
"promptUsers": "ユーザーを検索して始めましょう。",
"promptWorlds": "ワールドを検索して始めましょう。",
"emptyUsers": "ユーザーが見つかりません。",
"emptyWorlds": "ワールドが見つかりません。"
}
@@ -0,0 +1,18 @@
{
"title": "ค้นหา",
"tabs": {
"users": "ผู้ใช้",
"worlds": "เวิลด์"
},
"field": {
"labelUsers": "ค้นหาผู้ใช้",
"labelWorlds": "ค้นหาเวิลด์",
"placeholderUsers": "ค้นหาผู้ใช้ตามชื่อ…",
"placeholderWorlds": "ค้นหาเวิลด์ตามชื่อ…"
},
"button": "ค้นหา",
"promptUsers": "ค้นหาใครสักคนเพื่อเริ่มต้น",
"promptWorlds": "ค้นหาเวิลด์เพื่อเริ่มต้น",
"emptyUsers": "ไม่พบผู้ใช้",
"emptyWorlds": "ไม่พบเวิลด์"
}
+19
View File
@@ -36,6 +36,25 @@ export const statusMeta: Record<UserStatus, { label: string; color: string }> =
offline: { label: "Offline", color: "var(--status-offline)" }, 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: { export function avatarOf(p: {
userIcon: string; userIcon: string;
currentAvatarImageUrl: string; currentAvatarImageUrl: string;