refactor(frontend): move repeated Tailwind chains into BEM classes

This commit is contained in:
Vaka.pro
2026-04-27 20:42:21 +03:00
parent 17d59c3639
commit d46d4c4487
14 changed files with 273 additions and 101 deletions

View File

@@ -13,22 +13,20 @@ export function LanguageSwitcher({ className }: { className?: string }) {
return ( return (
<div <div
className={cn( className={cn(
'inline-flex items-center gap-1 rounded-md border border-border bg-surface/90 p-1 text-xs shadow-card', 'language-switcher',
className, className,
)} )}
aria-label={t('language.switch')} aria-label={t('language.switch')}
> >
<Languages className="ml-1 h-3.5 w-3.5 text-muted" aria-hidden /> <Languages className="language-switcher__icon" aria-hidden />
{languages.map((item) => ( {languages.map((item) => (
<button <button
key={item.value} key={item.value}
type="button" type="button"
onClick={() => setLanguage(item.value)} onClick={() => setLanguage(item.value)}
className={cn( className={cn(
'rounded px-2 py-1 font-semibold transition-colors', 'language-switcher__button',
language === item.value language === item.value && 'language-switcher__button--active',
? 'bg-primary text-primary-foreground'
: 'text-muted hover:bg-surface-muted hover:text-ink',
)} )}
aria-pressed={language === item.value} aria-pressed={language === item.value}
title={item.value === 'ru' ? t('language.ru') : t('language.en')} title={item.value === 'ru' ? t('language.ru') : t('language.en')}

View File

@@ -4,9 +4,9 @@ import { Footer } from './Footer';
export function AppShell() { export function AppShell() {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="app-shell">
<Header /> <Header />
<main className="container-page flex-1 py-6 sm:py-10"> <main className="app-shell__main">
<Outlet /> <Outlet />
</main> </main>
<Footer /> <Footer />

View File

@@ -17,15 +17,15 @@ export function Footer() {
}); });
return ( return (
<footer className="container-page mt-10 py-6 text-xs text-muted"> <footer className="app-footer">
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row"> <div className="app-footer__inner">
<div className="flex items-center gap-2"> <div className="app-footer__brand">
<Gift className="h-4 w-4" aria-hidden /> <Gift className="h-4 w-4" aria-hidden />
<span className="font-display text-sm">{t('app.name')}</span> <span className="app-footer__brand-name">{t('app.name')}</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="app-footer__meta">
<span>{t('footer.frontend', { version: FRONTEND_VERSION })}</span> <span>{t('footer.frontend', { version: FRONTEND_VERSION })}</span>
<span className="opacity-50">·</span> <span className="app-footer__separator">·</span>
<span>{t('footer.backend', { version: data?.backend ?? '...' })}</span> <span>{t('footer.backend', { version: data?.backend ?? '...' })}</span>
</div> </div>
</div> </div>

View File

@@ -44,21 +44,21 @@ export function Header() {
if (!user) return null; if (!user) return null;
return ( return (
<header className="container-page pt-6"> <header className="app-header">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface/80 px-4 py-3 backdrop-blur"> <div className="app-header__inner">
<Link to="/" className="flex items-center gap-2"> <Link to="/" className="app-header__brand">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card"> <span className="app-header__brand-mark">
<Gift className="h-4 w-4" /> <Gift className="h-4 w-4" />
</span> </span>
<div> <div>
<div className="font-display text-lg leading-tight">{t('app.name')}</div> <div className="app-header__brand-title">{t('app.name')}</div>
<div className="text-xs text-muted"> <div className="app-header__brand-subtitle">
{t('header.signedInAs', { name: user.displayName })} {t('header.signedInAs', { name: user.displayName })}
</div> </div>
</div> </div>
</Link> </Link>
<nav className="flex flex-wrap items-center gap-1"> <nav className="app-header__nav">
{links.map((l) => ( {links.map((l) => (
<NavLink <NavLink
key={l.to} key={l.to}
@@ -66,8 +66,8 @@ export function Header() {
end={l.end} end={l.end}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors', 'app-header__nav-link',
isActive ? 'bg-primary text-primary-foreground shadow-card' : 'text-ink hover:bg-ink/5', isActive && 'app-header__nav-link--active',
) )
} }
> >
@@ -80,8 +80,8 @@ export function Header() {
to={`/u/${friend.data.slug}`} to={`/u/${friend.data.slug}`}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors', 'app-header__nav-link',
isActive ? 'bg-primary text-primary-foreground shadow-card' : 'text-ink hover:bg-ink/5', isActive && 'app-header__nav-link--active',
) )
} }
> >
@@ -91,7 +91,7 @@ export function Header() {
)} )}
</nav> </nav>
<div className="flex items-center gap-1"> <div className="app-header__actions">
<LanguageSwitcher /> <LanguageSwitcher />
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -40,9 +40,9 @@ export function Modal({
if (!open) return null; if (!open) return null;
return createPortal( return createPortal(
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-6"> <div className="modal">
<div <div
className="absolute inset-0 bg-ink/40 backdrop-blur-sm animate-fade-in-up" className="modal__backdrop"
onClick={onClose} onClick={onClose}
aria-hidden aria-hidden
/> />
@@ -50,27 +50,21 @@ export function Modal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className={cn( className={cn(
'relative w-full bg-surface shadow-pop animate-fade-in-up', 'modal__panel',
'rounded-t-xl sm:rounded-xl', size === 'md' ? 'modal__panel--md' : 'modal__panel--lg',
size === 'md' ? 'sm:max-w-lg' : 'sm:max-w-2xl',
'max-h-[90vh] overflow-hidden flex flex-col',
)} )}
> >
<header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4"> <header className="modal__header">
<div className="min-w-0"> <div className="modal__title-wrap">
<h2 className="text-lg font-semibold text-ink">{title}</h2> <h2 className="modal__title">{title}</h2>
{description && <p className="mt-1 text-sm text-muted">{description}</p>} {description && <p className="modal__description">{description}</p>}
</div> </div>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close"> <Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</Button> </Button>
</header> </header>
<div className="overflow-y-auto px-5 py-5">{children}</div> <div className="modal__body">{children}</div>
{footer && ( {footer && <footer className="modal__footer">{footer}</footer>}
<footer className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
{footer}
</footer>
)}
</div> </div>
</div>, </div>,
document.body, document.body,

View File

@@ -15,9 +15,9 @@ export function ArchivePage() {
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
<section> <section className="page-section">
<h1 className="font-display text-3xl">{t('archive.title')}</h1> <h1 className="page-section__title">{t('archive.title')}</h1>
<p className="text-sm text-muted"> <p className="page-section__text">
{t('archive.description')} {t('archive.description')}
</p> </p>
</section> </section>
@@ -25,10 +25,10 @@ export function ArchivePage() {
{isLoading && <div className="text-muted">{t('common.loading')}</div>} {isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!isLoading && data && data.length === 0 && ( {!isLoading && data && data.length === 0 && (
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card"> <div className="empty-state">
<Archive className="h-10 w-10 text-muted" /> <Archive className="empty-state__icon" />
<h2 className="text-xl font-semibold">{t('archive.emptyTitle')}</h2> <h2 className="empty-state__title">{t('archive.emptyTitle')}</h2>
<p className="text-sm text-muted">{t('archive.emptyText')}</p> <p className="empty-state__text">{t('archive.emptyText')}</p>
</div> </div>
)} )}

View File

@@ -15,9 +15,9 @@ export function CompletedPage() {
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
<section> <section className="page-section">
<h1 className="font-display text-3xl">{t('completed.title')}</h1> <h1 className="page-section__title">{t('completed.title')}</h1>
<p className="text-sm text-muted"> <p className="page-section__text">
{t('completed.description')} {t('completed.description')}
</p> </p>
</section> </section>
@@ -25,10 +25,10 @@ export function CompletedPage() {
{isLoading && <div className="text-muted">{t('common.loading')}</div>} {isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!isLoading && data && data.length === 0 && ( {!isLoading && data && data.length === 0 && (
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card"> <div className="empty-state">
<CheckCircle2 className="h-10 w-10 text-muted" /> <CheckCircle2 className="empty-state__icon" />
<h2 className="text-xl font-semibold">{t('completed.emptyTitle')}</h2> <h2 className="empty-state__title">{t('completed.emptyTitle')}</h2>
<p className="text-sm text-muted"> <p className="empty-state__text">
{t('completed.emptyText')} {t('completed.emptyText')}
</p> </p>
</div> </div>

View File

@@ -26,10 +26,10 @@ export function DashboardPage() {
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
<section className="flex flex-wrap items-end justify-between gap-4"> <section className="page-section flex flex-wrap items-end justify-between gap-4">
<div> <div>
<h1 className="font-display text-3xl">{t('dashboard.title')}</h1> <h1 className="page-section__title">{t('dashboard.title')}</h1>
<p className="text-sm text-muted"> <p className="page-section__text">
{t('dashboard.description')} {t('dashboard.description')}
{user && ( {user && (
<Link <Link
@@ -61,11 +61,11 @@ export function DashboardPage() {
)} )}
{!isLoading && data && data.length === 0 && ( {!isLoading && data && data.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card"> <div className="empty-state gap-4">
<img src="/empty-state.svg" alt="" className="h-40 w-40 opacity-90" /> <img src="/empty-state.svg" alt="" className="empty-state__icon--image" />
<div> <div>
<h2 className="text-xl font-semibold">{t('dashboard.emptyTitle')}</h2> <h2 className="empty-state__title">{t('dashboard.emptyTitle')}</h2>
<p className="mt-1 text-sm text-muted"> <p className="empty-state__text mt-1">
{t('dashboard.emptyText')} {t('dashboard.emptyText')}
</p> </p>
</div> </div>

View File

@@ -47,11 +47,11 @@ export function LoginPage() {
}); });
return ( return (
<div className="flex min-h-screen flex-col"> <div className="app-shell">
<div className="container-page flex justify-end pt-6"> <div className="public-profile__toolbar">
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
<div className="container-page flex flex-1 items-center justify-center py-12"> <div className="app-shell__main flex items-center justify-center py-12">
<div className="w-full max-w-md animate-fade-in-up"> <div className="w-full max-w-md animate-fade-in-up">
<div className="mb-6 flex items-center justify-center gap-2"> <div className="mb-6 flex items-center justify-center gap-2">
<span className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card"> <span className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
@@ -60,7 +60,7 @@ export function LoginPage() {
<h1 className="font-display text-3xl">{t('app.name')}</h1> <h1 className="font-display text-3xl">{t('app.name')}</h1>
</div> </div>
<div className="rounded-xl border border-border bg-surface p-6 shadow-card sm:p-8"> <div className="profile-form p-6 sm:p-8">
<h2 className="mb-1 text-xl font-semibold">{t('login.title')}</h2> <h2 className="mb-1 text-xl font-semibold">{t('login.title')}</h2>
<p className="mb-6 text-sm text-muted"> <p className="mb-6 text-sm text-muted">
{t('login.description')} {t('login.description')}

View File

@@ -5,8 +5,8 @@ import { useI18n } from '@/i18n/i18n';
export function NotFoundPage() { export function NotFoundPage() {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<div className="flex min-h-screen items-center justify-center p-6"> <div className="app-shell items-center justify-center p-6">
<div className="max-w-md rounded-xl border border-border bg-surface p-8 text-center shadow-card"> <div className="empty-state max-w-md bg-surface p-8">
<h1 className="font-display text-4xl">404</h1> <h1 className="font-display text-4xl">404</h1>
<p className="mt-2 text-muted">{t('notFound.text')}</p> <p className="mt-2 text-muted">{t('notFound.text')}</p>
<Link to="/" className="mt-4 inline-block"> <Link to="/" className="mt-4 inline-block">

View File

@@ -122,9 +122,9 @@ export function ProfileSettingsPage() {
return ( return (
<div className="grid max-w-2xl gap-6"> <div className="grid max-w-2xl gap-6">
<section> <section className="page-section">
<h1 className="font-display text-3xl">{t('profile.title')}</h1> <h1 className="page-section__title">{t('profile.title')}</h1>
<p className="text-sm text-muted"> <p className="page-section__text">
{t('profile.publicPage')} {t('profile.publicPage')}
<code className="rounded bg-ink/5 px-1.5 py-0.5">/u/{data?.slug ?? '...'}</code>. <code className="rounded bg-ink/5 px-1.5 py-0.5">/u/{data?.slug ?? '...'}</code>.
</p> </p>
@@ -133,18 +133,18 @@ export function ProfileSettingsPage() {
{isLoading ? ( {isLoading ? (
<div className="text-muted">{t('common.loading')}</div> <div className="text-muted">{t('common.loading')}</div>
) : ( ) : (
<form className="grid gap-4 rounded-xl border border-border bg-surface p-6 shadow-card" onSubmit={submit}> <form className="profile-form" onSubmit={submit}>
<section className="flex flex-wrap items-center gap-4 rounded-md border border-border bg-surface-muted p-4"> <section className="profile-form__avatar-panel">
<span className="inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-primary text-primary-foreground"> <span className="profile-form__avatar-preview">
{avatarPreview ? ( {avatarPreview ? (
<img src={avatarPreview} alt="" className="h-16 w-16 object-cover" /> <img src={avatarPreview} alt="" className="profile-form__avatar-image" />
) : ( ) : (
<Gift className="h-6 w-6" /> <Gift className="h-6 w-6" />
)} )}
</span> </span>
<div className="min-w-0 flex-1"> <div className="profile-form__avatar-copy">
<h2 className="text-sm font-semibold">{t('profile.avatar')}</h2> <h2 className="profile-form__avatar-title">{t('profile.avatar')}</h2>
<p className="text-xs text-muted">{t('profile.avatarHint')}</p> <p className="profile-form__avatar-hint">{t('profile.avatarHint')}</p>
</div> </div>
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -199,7 +199,7 @@ export function ProfileSettingsPage() {
</span> </span>
)} )}
</div> </div>
<div className="flex items-center justify-end gap-2"> <div className="profile-form__actions">
<Button type="submit" disabled={!isDirty || isSubmitting}> <Button type="submit" disabled={!isDirty || isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />} {isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t('common.saveChanges')} {t('common.saveChanges')}

View File

@@ -55,17 +55,17 @@ export function PublicProfilePage() {
}, [wishes.data, markSeen, queryClient, slug]); }, [wishes.data, markSeen, queryClient, slug]);
return ( return (
<div className="flex min-h-screen flex-col"> <div className="public-profile">
<div className="container-page flex justify-end pt-6"> <div className="public-profile__toolbar">
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
<main className="container-page flex-1 py-10"> <main className="public-profile__main">
{profile.isLoading && <div className="text-muted">{t('common.loading')}</div>} {profile.isLoading && <div className="text-muted">{t('common.loading')}</div>}
{profile.isError && ( {profile.isError && (
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface p-8 text-center shadow-card"> <div className="empty-state mx-auto max-w-lg bg-surface p-8">
<h1 className="font-display text-2xl">{t('public.notFoundTitle')}</h1> <h1 className="empty-state__title">{t('public.notFoundTitle')}</h1>
<p className="mt-2 text-sm text-muted"> <p className="empty-state__text mt-2">
{t('public.notFoundText')} {t('public.notFoundText')}
</p> </p>
</div> </div>
@@ -73,32 +73,32 @@ export function PublicProfilePage() {
{profile.data && ( {profile.data && (
<> <>
<section className="mb-10 flex flex-col items-center gap-3 text-center"> <section className="public-profile__hero">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-card"> <span className="public-profile__avatar">
{profile.data.avatarUrl ? ( {profile.data.avatarUrl ? (
<img <img
src={profile.data.avatarUrl} src={profile.data.avatarUrl}
alt="" alt=""
className="h-14 w-14 rounded-full object-cover" className="public-profile__avatar-image"
/> />
) : ( ) : (
<Gift className="h-6 w-6" /> <Gift className="h-6 w-6" />
)} )}
</span> </span>
<h1 className="font-display text-4xl"> <h1 className="public-profile__title">
{t('public.wishlistTitle', { name: profile.data.displayName })} {t('public.wishlistTitle', { name: profile.data.displayName })}
</h1> </h1>
{profile.data.bio && ( {profile.data.bio && (
<p className="max-w-xl text-muted">{profile.data.bio}</p> <p className="public-profile__bio">{profile.data.bio}</p>
)} )}
</section> </section>
{wishes.isLoading && <div className="text-muted">{t('public.loadingWishes')}</div>} {wishes.isLoading && <div className="text-muted">{t('public.loadingWishes')}</div>}
{wishes.data && wishes.data.length === 0 && ( {wishes.data && wishes.data.length === 0 && (
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card"> <div className="empty-state mx-auto max-w-lg">
<h2 className="text-xl font-semibold">{t('public.emptyTitle')}</h2> <h2 className="empty-state__title">{t('public.emptyTitle')}</h2>
<p className="mt-1 text-sm text-muted">{t('public.emptyText')}</p> <p className="empty-state__text mt-1">{t('public.emptyText')}</p>
</div> </div>
)} )}

View File

@@ -12,9 +12,9 @@ export function TrashPage() {
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
<section> <section className="page-section">
<h1 className="font-display text-3xl">{t('trash.title')}</h1> <h1 className="page-section__title">{t('trash.title')}</h1>
<p className="text-sm text-muted"> <p className="page-section__text">
{t('trash.description', { days: dayCount(TRASH_RETENTION_DAYS) })} {t('trash.description', { days: dayCount(TRASH_RETENTION_DAYS) })}
</p> </p>
</section> </section>
@@ -22,10 +22,10 @@ export function TrashPage() {
{isLoading && <div className="text-muted">{t('common.loading')}</div>} {isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!isLoading && data && data.length === 0 && ( {!isLoading && data && data.length === 0 && (
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card"> <div className="empty-state">
<Trash2 className="h-10 w-10 text-muted" /> <Trash2 className="empty-state__icon" />
<h2 className="text-xl font-semibold">{t('trash.emptyTitle')}</h2> <h2 className="empty-state__title">{t('trash.emptyTitle')}</h2>
<p className="text-sm text-muted"> <p className="empty-state__text">
{t('trash.emptyText', { days: dayCount(TRASH_RETENTION_DAYS) })} {t('trash.emptyText', { days: dayCount(TRASH_RETENTION_DAYS) })}
</p> </p>
</div> </div>

View File

@@ -29,6 +29,186 @@
@layer components { @layer components {
/* BEM-friendly classes: kept parallel to Tailwind utilities where possible. */ /* BEM-friendly classes: kept parallel to Tailwind utilities where possible. */
.app-shell {
@apply flex min-h-screen flex-col;
}
.app-shell__main {
@apply mx-auto w-full max-w-6xl flex-1 px-4 py-6 sm:px-6 sm:py-10 lg:px-8;
}
.app-header {
@apply mx-auto w-full max-w-6xl px-4 pt-6 sm:px-6 lg:px-8;
}
.app-header__inner {
@apply flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface/80 px-4 py-3 backdrop-blur;
}
.app-header__brand {
@apply flex items-center gap-2;
}
.app-header__brand-mark {
@apply inline-flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card;
}
.app-header__brand-title {
@apply font-display text-lg leading-tight;
}
.app-header__brand-subtitle {
@apply text-xs text-muted;
}
.app-header__nav {
@apply flex flex-wrap items-center gap-1;
}
.app-header__nav-link {
@apply inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-ink transition-colors hover:bg-ink/5;
}
.app-header__nav-link--active {
@apply bg-primary text-primary-foreground shadow-card hover:bg-primary;
}
.app-header__actions {
@apply flex items-center gap-1;
}
.app-footer {
@apply mx-auto mt-10 w-full max-w-6xl px-4 py-6 text-xs text-muted sm:px-6 lg:px-8;
}
.app-footer__inner {
@apply flex flex-col items-center justify-between gap-2 sm:flex-row;
}
.app-footer__brand {
@apply flex items-center gap-2;
}
.app-footer__brand-name {
@apply font-display text-sm;
}
.app-footer__meta {
@apply flex items-center gap-3;
}
.app-footer__separator {
@apply opacity-50;
}
.language-switcher {
@apply inline-flex items-center gap-1 rounded-md border border-border bg-surface/90 p-1 text-xs shadow-card;
}
.language-switcher__icon {
@apply ml-1 h-3.5 w-3.5 text-muted;
}
.language-switcher__button {
@apply rounded px-2 py-1 font-semibold text-muted transition-colors hover:bg-surface-muted hover:text-ink;
}
.language-switcher__button--active {
@apply bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground;
}
.empty-state {
@apply flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card;
}
.empty-state__icon {
@apply h-10 w-10 text-muted;
}
.empty-state__icon--image {
@apply h-40 w-40 opacity-90;
}
.empty-state__title {
@apply text-xl font-semibold;
}
.empty-state__text {
@apply text-sm text-muted;
}
.page-section {
@apply grid gap-1;
}
.page-section__title {
@apply font-display text-3xl;
}
.page-section__text {
@apply text-sm text-muted;
}
.public-profile {
@apply flex min-h-screen flex-col;
}
.public-profile__toolbar {
@apply mx-auto flex w-full max-w-6xl justify-end px-4 pt-6 sm:px-6 lg:px-8;
}
.public-profile__main {
@apply mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6 lg:px-8;
}
.public-profile__hero {
@apply mb-10 flex flex-col items-center gap-3 text-center;
}
.public-profile__avatar {
@apply inline-flex h-14 w-14 items-center justify-center overflow-hidden rounded-full bg-primary text-primary-foreground shadow-card;
}
.public-profile__avatar-image {
@apply h-14 w-14 rounded-full object-cover;
}
.public-profile__title {
@apply font-display text-4xl;
}
.public-profile__bio {
@apply max-w-xl text-muted;
}
.profile-form {
@apply grid gap-4 rounded-xl border border-border bg-surface p-6 shadow-card;
}
.profile-form__avatar-panel {
@apply flex flex-wrap items-center gap-4 rounded-md border border-border bg-surface-muted p-4;
}
.profile-form__avatar-preview {
@apply inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-primary text-primary-foreground;
}
.profile-form__avatar-image {
@apply h-16 w-16 object-cover;
}
.profile-form__avatar-copy {
@apply min-w-0 flex-1;
}
.profile-form__avatar-title {
@apply text-sm font-semibold;
}
.profile-form__avatar-hint {
@apply text-xs text-muted;
}
.profile-form__actions {
@apply flex items-center justify-end gap-2;
}
.modal {
@apply fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-6;
}
.modal__backdrop {
@apply absolute inset-0 animate-fade-in-up bg-ink/40 backdrop-blur-sm;
}
.modal__panel {
@apply relative flex max-h-[90vh] w-full animate-fade-in-up flex-col overflow-hidden rounded-t-xl bg-surface shadow-pop sm:rounded-xl;
}
.modal__panel--md {
@apply sm:max-w-lg;
}
.modal__panel--lg {
@apply sm:max-w-2xl;
}
.modal__header {
@apply flex items-start justify-between gap-4 border-b border-border px-5 py-4;
}
.modal__title-wrap {
@apply min-w-0;
}
.modal__title {
@apply text-lg font-semibold text-ink;
}
.modal__description {
@apply mt-1 text-sm text-muted;
}
.modal__body {
@apply overflow-y-auto px-5 py-5;
}
.modal__footer {
@apply flex items-center justify-end gap-2 border-t border-border px-5 py-4;
}
.wish-card { .wish-card {
@apply relative flex flex-col overflow-hidden rounded-lg bg-surface shadow-card transition-transform duration-200; @apply relative flex flex-col overflow-hidden rounded-lg bg-surface shadow-card transition-transform duration-200;
} }