mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
♻️ refactor: shared HeroHeader, i18n wiring, group owner fix
This commit is contained in:
@@ -3,10 +3,13 @@ import type { Group } from "../../../../shared/types/group";
|
||||
import { Avatar, Banner, Fact, Section, Skeleton, StatTile, Tag } from "../../components/ui";
|
||||
import { api } from "../../lib/api";
|
||||
import { useAsync } from "../../lib/useAsync";
|
||||
import { compactNumber, formatDate, prettyTag } from "../../lib/format";
|
||||
import { compactNumber, formatDate, prettyTag, tagsWithPrefix } from "../../lib/format";
|
||||
import { useT } from "../../lib/i18n";
|
||||
import { useNav } from "../navigation/NavContext";
|
||||
import { useGroups } from "../../store/groups";
|
||||
import { useSocial } from "../../store/social";
|
||||
import { COL_WIDE } from "../../lib/layout";
|
||||
import { HeroHeader } from "../shared/HeroHeader";
|
||||
import "../profile/profile.css";
|
||||
|
||||
export function GroupView({ groupId }: { groupId: string }) {
|
||||
@@ -21,18 +24,18 @@ export function GroupView({ groupId }: { groupId: string }) {
|
||||
}
|
||||
|
||||
function GroupCard({ group }: { group: Group }) {
|
||||
const t = useT();
|
||||
const { openUser } = useNav();
|
||||
const authorTags = (group.tags ?? []).filter((t) => t.startsWith("group_tag_"));
|
||||
const ownerName = useSocial((s) =>
|
||||
group.ownerId ? s.users[group.ownerId]?.displayName : undefined,
|
||||
);
|
||||
const authorTags = tagsWithPrefix(group.tags ?? [], "group_tag_");
|
||||
|
||||
return (
|
||||
<article className="profile flex min-h-full w-full flex-col bg-surface pb-12">
|
||||
<div
|
||||
className="profile__banner"
|
||||
style={{ backgroundImage: group.bannerUrl ? `url(${group.bannerUrl})` : undefined }}
|
||||
/>
|
||||
|
||||
<div className={`${COL_WIDE} relative flex items-end gap-5`} style={{ marginTop: -64 }}>
|
||||
<Avatar src={group.iconUrl} name={group.name} size={112} className="!rounded-2xl" />
|
||||
<HeroHeader
|
||||
banner={group.bannerUrl}
|
||||
media={<Avatar src={group.iconUrl} name={group.name} size={112} className="!rounded-2xl" />}
|
||||
body={
|
||||
<div className="min-w-0 pb-1">
|
||||
<h2 className="flex items-center gap-2 text-[30px] font-bold leading-tight tracking-[-0.6px]">
|
||||
{group.name}
|
||||
@@ -40,12 +43,12 @@ function GroupCard({ group }: { group: Group }) {
|
||||
</h2>
|
||||
{group.ownerId ? (
|
||||
<p className="mt-1 text-[14px] text-muted">
|
||||
owned by{" "}
|
||||
{t("group:ownedByPrefix")}{" "}
|
||||
<button
|
||||
onClick={() => openUser(group.ownerId!)}
|
||||
className="font-semibold text-text transition-colors hover:text-accent"
|
||||
>
|
||||
View owner
|
||||
{ownerName ?? t("group:ownerUnknown")}
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
@@ -56,18 +59,17 @@ function GroupCard({ group }: { group: Group }) {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${COL_WIDE} mt-6 flex flex-col gap-5`}>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<StatTile
|
||||
icon={<Users size={15} />}
|
||||
label="Members"
|
||||
label={t("group:stats.members")}
|
||||
value={typeof group.memberCount === "number" ? compactNumber(group.memberCount) : "—"}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<UserCheck size={15} />}
|
||||
label="Online"
|
||||
label={t("group:stats.online")}
|
||||
value={
|
||||
typeof group.onlineMemberCount === "number"
|
||||
? compactNumber(group.onlineMemberCount)
|
||||
@@ -77,13 +79,13 @@ function GroupCard({ group }: { group: Group }) {
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Globe size={15} />}
|
||||
label="Languages"
|
||||
label={t("group:stats.languages")}
|
||||
value={group.languages?.length ? group.languages.join(", ").toUpperCase() : "—"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{group.description ? (
|
||||
<Section title="About">
|
||||
<Section title={t("group:sections.about")}>
|
||||
<p className="whitespace-pre-wrap text-[14px] leading-relaxed text-muted">
|
||||
{group.description}
|
||||
</p>
|
||||
@@ -91,7 +93,7 @@ function GroupCard({ group }: { group: Group }) {
|
||||
) : null}
|
||||
|
||||
{group.rules ? (
|
||||
<Section title="Rules">
|
||||
<Section title={t("group:sections.rules")}>
|
||||
<p className="whitespace-pre-wrap text-[14px] leading-relaxed text-muted">
|
||||
{group.rules}
|
||||
</p>
|
||||
@@ -99,28 +101,29 @@ function GroupCard({ group }: { group: Group }) {
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2">
|
||||
<Section title="Details">
|
||||
<Section title={t("group:sections.details")}>
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||
{group.joinState ? <Fact label="Joining" value={group.joinState} /> : null}
|
||||
{group.createdAt ? (
|
||||
<Fact label="Created" value={formatDate(group.createdAt)} />
|
||||
{group.joinState ? (
|
||||
<Fact label={t("group:facts.joining")} value={group.joinState} />
|
||||
) : null}
|
||||
<Fact label="Group ID" value={group.id} mono />
|
||||
{group.createdAt ? (
|
||||
<Fact label={t("group:facts.created")} value={formatDate(group.createdAt)} />
|
||||
) : null}
|
||||
<Fact label={t("group:facts.groupId")} value={group.id} mono />
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
{authorTags.length ? (
|
||||
<Section title="Tags">
|
||||
<Section title={t("group:sections.tags")}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{authorTags.map((t) => (
|
||||
<Tag key={t}>{prettyTag(t, "group_tag_")}</Tag>
|
||||
{authorTags.map((tag) => (
|
||||
<Tag key={tag}>{prettyTag(tag, "group_tag_")}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</HeroHeader>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ import { WorldsSection, FavoriteWorldsSection, WorldSearch } from "./WorldsSecti
|
||||
import { GroupsSection } from "./GroupsSection";
|
||||
import { LocationSection } from "./LocationSection";
|
||||
import { COL, COL_WIDE } from "../../lib/layout";
|
||||
import { formatDate, formatDateTime, platformLabel, prettyTag } from "../../lib/format";
|
||||
import { HeroHeader } from "../shared/HeroHeader";
|
||||
import { formatDate, formatDateTime, platformLabel, prettyLink, prettyTag } from "../../lib/format";
|
||||
import "./profile.css";
|
||||
|
||||
export function ProfileView({ target }: { target: "me" | string }) {
|
||||
@@ -64,13 +65,11 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
|
||||
const showcasedBadges = (profile.badges ?? []).filter((b) => b.showcased);
|
||||
|
||||
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-6`} style={{ marginTop: -72 }}>
|
||||
<HeroHeader
|
||||
banner={banner}
|
||||
gap="gap-6"
|
||||
overlap={-72}
|
||||
media={
|
||||
<div className="profile__avatar" style={{ "--ring": trust.color } as React.CSSProperties}>
|
||||
<Avatar src={avatar} name={profile.displayName} size={116} />
|
||||
<span
|
||||
@@ -79,7 +78,8 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
|
||||
title={statusLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
}
|
||||
body={
|
||||
<div className="min-w-0 flex-1 pb-2">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<h2 className="text-[32px] font-bold tracking-[-0.6px]">{profile.displayName}</h2>
|
||||
@@ -113,8 +113,9 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canAdd ? (
|
||||
}
|
||||
actions={
|
||||
canAdd ? (
|
||||
<div className="shrink-0 pb-2">
|
||||
<AddFriendButton add={addFriend} />
|
||||
</div>
|
||||
@@ -130,10 +131,9 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
|
||||
<MoreHorizontal size={18} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`${COL_WIDE} mt-6 flex flex-col gap-5`}>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<LocationSection location={profile.location} />
|
||||
|
||||
<Tabs
|
||||
@@ -303,7 +303,6 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
|
||||
<GroupsSection userId={profile.id} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{menu ? (
|
||||
<ContextMenu
|
||||
@@ -315,7 +314,7 @@ function ProfileCard({ profile }: { profile: UserProfile }) {
|
||||
) : null}
|
||||
|
||||
{modal}
|
||||
</article>
|
||||
</HeroHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -392,14 +391,6 @@ function ProfileSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function prettyLink(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function stateLabel(state: NonNullable<UserProfile["state"]>, t: ReturnType<typeof useT>): string {
|
||||
if (state === "online") return t("profile:state.online");
|
||||
if (state === "active") return t("profile:state.active");
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { COL_WIDE } from "../../lib/layout";
|
||||
import "../profile/profile.css";
|
||||
|
||||
export function HeroHeader({
|
||||
banner,
|
||||
media,
|
||||
body,
|
||||
actions,
|
||||
gap = "gap-5",
|
||||
overlap = -64,
|
||||
contentClassName = "mt-6 flex flex-col gap-5",
|
||||
children,
|
||||
}: {
|
||||
banner?: string;
|
||||
media: ReactNode;
|
||||
body: ReactNode;
|
||||
actions?: ReactNode;
|
||||
gap?: string;
|
||||
overlap?: number;
|
||||
contentClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
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}`} style={{ marginTop: overlap }}>
|
||||
{media}
|
||||
{body}
|
||||
{actions}
|
||||
</div>
|
||||
|
||||
<div className={`${COL_WIDE} ${contentClassName}`}>{children}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import type { World } from "../../../../shared/types/world";
|
||||
import { Banner, Button, Fact, Section, Skeleton, StatTile, Tag } from "../../components/ui";
|
||||
import { api } from "../../lib/api";
|
||||
import { useAsync } from "../../lib/useAsync";
|
||||
import { compactNumber, formatDate, prettyTag } from "../../lib/format";
|
||||
import { compactNumber, formatDate, prettyTag, tagsWithPrefix } from "../../lib/format";
|
||||
import { useWorlds } from "../../store/worlds";
|
||||
import { useNav } from "../navigation/NavContext";
|
||||
import { useT } from "../../lib/i18n";
|
||||
import { COL_WIDE } from "../../lib/layout";
|
||||
import { HeroHeader } from "../shared/HeroHeader";
|
||||
import { CreateInstanceModal } from "./CreateInstanceModal";
|
||||
import "../profile/profile.css";
|
||||
|
||||
@@ -31,22 +32,20 @@ function WorldCard({ world }: { world: World }) {
|
||||
const players = world.occupants;
|
||||
|
||||
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 }}>
|
||||
<HeroHeader
|
||||
banner={banner}
|
||||
media={
|
||||
<div className="world__thumb">
|
||||
{world.thumbnailImageUrl || world.imageUrl ? (
|
||||
<img src={world.thumbnailImageUrl || world.imageUrl} alt="" />
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
body={
|
||||
<div className="min-w-0 pb-1">
|
||||
<h2 className="text-[30px] font-bold leading-tight tracking-[-0.6px]">{world.name}</h2>
|
||||
<p className="mt-1 text-[14px] text-muted">
|
||||
by{" "}
|
||||
{t("world:detail.byPrefix")}{" "}
|
||||
<button
|
||||
onClick={() => openUser(world.authorId)}
|
||||
className="font-semibold text-text transition-colors hover:text-accent"
|
||||
@@ -62,41 +61,42 @@ function WorldCard({ world }: { world: World }) {
|
||||
{world.platforms?.android ? <Tag color="var(--status-join)">Quest</Tag> : null}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<Button className="mb-1 ml-auto shrink-0" onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={15} />
|
||||
{t("world:createInstance")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CreateInstanceModal worldId={world.id} open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
|
||||
<CreateInstanceModal
|
||||
worldId={world.id}
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
/>
|
||||
|
||||
<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="Players now"
|
||||
label={t("world:detail.stats.playersNow")}
|
||||
value={compactNumber(players)}
|
||||
live={players > 0}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Heart size={15} />}
|
||||
label="Favorites"
|
||||
label={t("world:detail.stats.favorites")}
|
||||
value={compactNumber(world.favorites)}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Globe size={15} />}
|
||||
label="Visits"
|
||||
label={t("world:detail.stats.visits")}
|
||||
value={world.visits ? compactNumber(world.visits) : "—"}
|
||||
/>
|
||||
<StatTile icon={<Users size={15} />} label="Capacity" value={`${world.capacity}`} />
|
||||
<StatTile
|
||||
icon={<Users size={15} />}
|
||||
label={t("world:detail.stats.capacity")}
|
||||
value={`${world.capacity}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{world.description ? (
|
||||
<Section title="Description">
|
||||
<Section title={t("world:detail.sections.description")}>
|
||||
<p className="whitespace-pre-wrap text-[14px] leading-relaxed text-muted">
|
||||
{world.description}
|
||||
</p>
|
||||
@@ -104,12 +104,12 @@ function WorldCard({ world }: { world: World }) {
|
||||
) : null}
|
||||
|
||||
{world.previewYoutubeId ? (
|
||||
<Section title="Trailer">
|
||||
<Section title={t("world:detail.sections.trailer")}>
|
||||
<div className="aspect-video overflow-hidden rounded-lg border border-border">
|
||||
<iframe
|
||||
className="size-full"
|
||||
src={`https://www.youtube.com/embed/${world.previewYoutubeId}`}
|
||||
title="World trailer"
|
||||
title={t("world:detail.sections.trailer")}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
@@ -118,52 +118,59 @@ function WorldCard({ world }: { world: World }) {
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2">
|
||||
<Section title="Details">
|
||||
<Section title={t("world:detail.sections.details")}>
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||
{world.recommendedCapacity ? (
|
||||
<Fact label="Recommended" value={`${world.recommendedCapacity} players`} />
|
||||
<Fact
|
||||
label={t("world:detail.facts.recommended")}
|
||||
value={t("world:detail.recommendedValue", { count: world.recommendedCapacity })}
|
||||
/>
|
||||
) : null}
|
||||
{typeof world.popularity === "number" ? (
|
||||
<Fact label="Popularity" value={`${world.popularity} / 6`} />
|
||||
<Fact label={t("world:detail.facts.popularity")} value={`${world.popularity} / 6`} />
|
||||
) : null}
|
||||
{typeof world.heat === "number" ? (
|
||||
<Fact label="Heat" value={`${world.heat} / 6`} />
|
||||
<Fact label={t("world:detail.facts.heat")} value={`${world.heat} / 6`} />
|
||||
) : null}
|
||||
{world.version ? (
|
||||
<Fact label={t("world:detail.facts.version")} value={`${world.version}`} />
|
||||
) : null}
|
||||
{world.version ? <Fact label="Version" value={`${world.version}`} /> : null}
|
||||
{world.publishedAt ? (
|
||||
<Fact label="Published" value={formatDate(world.publishedAt)} />
|
||||
<Fact label={t("world:detail.facts.published")} value={formatDate(world.publishedAt)} />
|
||||
) : null}
|
||||
{world.labsPublishedAt ? (
|
||||
<Fact label="Community Labs" value={formatDate(world.labsPublishedAt)} />
|
||||
<Fact
|
||||
label={t("world:detail.facts.communityLabs")}
|
||||
value={formatDate(world.labsPublishedAt)}
|
||||
/>
|
||||
) : null}
|
||||
{world.createdAt ? (
|
||||
<Fact label="Created" value={formatDate(world.createdAt)} />
|
||||
<Fact label={t("world:detail.facts.created")} value={formatDate(world.createdAt)} />
|
||||
) : null}
|
||||
{world.updatedAt ? (
|
||||
<Fact label="Updated" value={formatDate(world.updatedAt)} />
|
||||
<Fact label={t("world:detail.facts.updated")} value={formatDate(world.updatedAt)} />
|
||||
) : null}
|
||||
<Fact label="World ID" value={world.id} mono />
|
||||
<Fact label={t("world:detail.facts.worldId")} value={world.id} mono />
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
{world.tags.length ? (
|
||||
<Section title="Tags">
|
||||
<Section title={t("world:detail.sections.tags")}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{world.tags.filter((t) => t.startsWith("author_tag_")).length ? (
|
||||
world.tags
|
||||
.filter((t) => t.startsWith("author_tag_"))
|
||||
.map((t) => <Tag key={t}>{prettyTag(t, "author_tag_")}</Tag>)
|
||||
{tagsWithPrefix(world.tags, "author_tag_").length ? (
|
||||
tagsWithPrefix(world.tags, "author_tag_").map((tag) => (
|
||||
<Tag key={tag}>{prettyTag(tag, "author_tag_")}</Tag>
|
||||
))
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-[13px] text-faint">
|
||||
<TagIcon size={13} /> No author tags
|
||||
<TagIcon size={13} /> {t("world:detail.noAuthorTags")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</HeroHeader>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user