♻️ 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 { 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>
);
}
+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 { 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>
);
}