♻️ refactor: shared HeroHeader, i18n wiring, group owner fix

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