feat(ui): PageHeader/PageContainer foundation (piloted on Contacts); profile info-first on mobile

- New shared PageHeader + PageContainer (width tokens: narrow/list/detail/wide)
  to end the max-w drift between sections; piloted on ContactsPage
- Profile: the identity/contact sidebar now comes first on mobile (order-first /
  lg:order-none) instead of being buried under every content section
This commit is contained in:
SkateSpots 2026-09-26 19:17:55 +07:00
parent 10ab45383d
commit 4d2c29edb0
3 changed files with 76 additions and 12 deletions

View file

@ -0,0 +1,66 @@
import type { ComponentType, ReactNode } from 'react';
import { cn } from '@/lib/utils';
/**
* Shared page header + width tokens (UX foundations).
*
* Pages previously each hand-rolled their own title markup and container
* width (max-w-2xl / -3xl / -4xl / -7xl / unbounded), so navigating between
* sections felt like moving between different apps. New/edited pages should
* use these; existing pages can be migrated incrementally.
*/
export function PageHeader({
title,
subtitle,
icon: Icon,
accent = '#ccff00',
actions,
className,
}: {
title: ReactNode;
subtitle?: ReactNode;
icon?: ComponentType<{ className?: string }>;
/** Title colour. Defaults to the app's lime accent. */
accent?: string;
actions?: ReactNode;
className?: string;
}) {
return (
<div className={cn('mb-5 flex items-start justify-between gap-3', className)}>
<div className="min-w-0">
<h1
className="flex items-center gap-2 text-3xl font-black uppercase leading-none sm:text-4xl"
style={{ color: accent, fontFamily: 'Impact, "Arial Black", sans-serif' }}
>
{Icon && <Icon className="h-7 w-7 shrink-0 sm:h-8 sm:w-8" />}
<span className="truncate">{title}</span>
</h1>
{subtitle && <p className="mt-2 text-sm text-gray-400">{subtitle}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
);
}
const WIDTHS = {
narrow: 'max-w-2xl',
list: 'max-w-3xl',
detail: 'max-w-4xl',
wide: 'max-w-7xl',
} as const;
export function PageContainer({
children,
width = 'detail',
className,
}: {
children: ReactNode;
width?: keyof typeof WIDTHS;
className?: string;
}) {
return (
<div className={cn('container mx-auto px-4 py-6', WIDTHS[width], className)}>
{children}
</div>
);
}

View file

@ -3,6 +3,7 @@ import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { PageHeader } from '@/components/PageHeader';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
@ -221,17 +222,12 @@ export default function ContactsPage() {
<Layout>
<div className="container max-w-2xl px-4 py-6">
{/* Header */}
<div className="mb-4">
<h1
className="flex items-center gap-2 text-3xl font-black uppercase text-teal-400"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
>
<Users className="h-7 w-7" /> Contacts
</h1>
<p className="mt-1 text-sm text-gray-400">
Your scene — crew-mates, follows &amp; conversations
</p>
</div>
<PageHeader
title="Contacts"
icon={Users}
accent="#2dd4bf"
subtitle="Your scene — crew-mates, follows &amp; conversations"
/>
{/* Search */}
<div className="relative mb-4">

View file

@ -706,7 +706,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</div>
{/* Sidebar */}
<div className="space-y-4 min-w-0">
{/* On mobile the identity/contact cards come first — the info card was
previously buried below every content section on phones. */}
<div className="space-y-4 min-w-0 order-first lg:order-none">
{/* Zap achievements (only renders when this profile has sent zaps) */}
<ZapBadgesCard pubkey={pubkey} />