mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
♻️ refactor: WorldCard component, PresenceLabel, etc
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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": "ไม่พบเวิลด์"
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user