Merge pull request 'refactor(frontend): move repeated Tailwind chains into BEM classes' (#14) from refactor/frontend-bem-classes into main
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
@@ -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')}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="grid grid-cols-1 items-center gap-3 rounded-lg border border-border bg-surface/80 px-4 py-3 backdrop-blur lg:grid-cols-[auto_minmax(0,1fr)_auto]">
|
<div className="app-header__inner">
|
||||||
<Link to="/" className="flex min-w-0 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 className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-display text-lg leading-tight">{t('app.name')}</div>
|
<div className="app-header__brand-title">{t('app.name')}</div>
|
||||||
<div className="truncate 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 min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap lg:justify-center">
|
<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 shrink-0 items-center gap-1 justify-self-start lg:justify-self-end">
|
<div className="app-header__actions">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ 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 items-center justify-between gap-3 pt-6">
|
<div className="public-profile__toolbar">
|
||||||
{user ? (
|
{user ? (
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<Button variant="secondary" size="sm">
|
<Button variant="secondary" size="sm">
|
||||||
@@ -71,13 +71,13 @@ export function PublicProfilePage() {
|
|||||||
)}
|
)}
|
||||||
<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>
|
||||||
@@ -85,32 +85,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 grid grid-cols-1 items-center gap-3 rounded-lg border border-border bg-surface/80 px-4 py-3 backdrop-blur lg:grid-cols-[auto_minmax(0,1fr)_auto];
|
||||||
|
}
|
||||||
|
.app-header__brand {
|
||||||
|
@apply flex min-w-0 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 truncate text-xs text-muted;
|
||||||
|
}
|
||||||
|
.app-header__nav {
|
||||||
|
@apply flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap lg:justify-center;
|
||||||
|
}
|
||||||
|
.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 shrink-0 items-center gap-1 justify-self-start lg:justify-self-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 items-center justify-between gap-3 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user