7 Commits

15 changed files with 342 additions and 143 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,50 @@ 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"> <div className="app-header__actions">
<LanguageSwitcher />
<Button
variant="ghost"
size="sm"
className="app-header__action"
onClick={() => navigate('/settings')}
title={t('header.profileSettings')}
aria-label={t('header.profileSettings')}
>
<UserCog className="h-4 w-4" />
<span className="app-header__action-text">{t('header.profile')}</span>
</Button>
<Button
variant="ghost"
size="sm"
className="app-header__action"
onClick={() => {
void logout().then(() => navigate('/login'));
}}
title={t('header.logout')}
aria-label={t('header.logout')}
>
<LogOut className="h-4 w-4" />
<span className="app-header__action-text">{t('header.logout')}</span>
</Button>
</div>
</div>
<nav className="app-header__nav">
{links.map((l) => ( {links.map((l) => (
<NavLink <NavLink
key={l.to} key={l.to}
@@ -66,8 +95,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 +109,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',
) )
} }
> >
@@ -90,30 +119,6 @@ export function Header() {
</NavLink> </NavLink>
)} )}
</nav> </nav>
<div className="flex shrink-0 items-center gap-1 justify-self-start lg:justify-self-end">
<LanguageSwitcher />
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/settings')}
title={t('header.profileSettings')}
>
<UserCog className="h-4 w-4" />
{t('header.profile')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
void logout().then(() => navigate('/login'));
}}
>
<LogOut className="h-4 w-4" />
{t('header.logout')}
</Button>
</div>
</div>
</header> </header>
); );
} }

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

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -25,6 +25,7 @@ export function ProfileSettingsPage() {
const refresh = useAuthStore((s) => s.refresh); const refresh = useAuthStore((s) => s.refresh);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarFailed, setAvatarFailed] = useState(false);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['profile'], queryKey: ['profile'],
@@ -120,11 +121,15 @@ export function ProfileSettingsPage() {
const avatarPreview = watch('avatarUrl') || data?.avatarUrl; const avatarPreview = watch('avatarUrl') || data?.avatarUrl;
useEffect(() => {
setAvatarFailed(false);
}, [avatarPreview]);
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 +138,23 @@ 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 && !avatarFailed ? (
<img src={avatarPreview} alt="" className="h-16 w-16 object-cover" /> <img
src={avatarPreview}
alt=""
className="profile-form__avatar-image"
onError={() => setAvatarFailed(true)}
/>
) : ( ) : (
<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 +209,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

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { import type {
@@ -19,6 +19,7 @@ export function PublicProfilePage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const { slug = '' } = useParams<{ slug: string }>(); const { slug = '' } = useParams<{ slug: string }>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [avatarFailed, setAvatarFailed] = useState(false);
const profile = useQuery({ const profile = useQuery({
queryKey: ['public-profile', slug], queryKey: ['public-profile', slug],
@@ -57,9 +58,13 @@ export function PublicProfilePage() {
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [wishes.data, markSeen, queryClient, slug]); }, [wishes.data, markSeen, queryClient, slug]);
useEffect(() => {
setAvatarFailed(false);
}, [profile.data?.avatarUrl]);
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 +76,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 +90,33 @@ 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 && !avatarFailed ? (
<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"
onError={() => setAvatarFailed(true)}
/> />
) : ( ) : (
<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,192 @@
@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 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 mt-3 flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap rounded-lg border border-border bg-surface/80 px-4 py-2 backdrop-blur;
}
.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 whitespace-nowrap;
}
.app-header__action {
@apply shrink-0;
}
.app-header__action-text {
@apply hidden xl:inline;
}
.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;
} }

View File

@@ -4,13 +4,6 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Static files with long cache
location ~* \.(?:js|css|woff2?|svg|png|jpg|jpeg|gif|webp|ico)$ {
expires 7d;
add_header Cache-Control "public";
try_files $uri =404;
}
# API proxy # API proxy
location /api/ { location /api/ {
proxy_pass http://family-wishlist-backend:3000/api/; proxy_pass http://family-wishlist-backend:3000/api/;
@@ -24,13 +17,20 @@ server {
} }
# Uploaded files (images) # Uploaded files (images)
location /uploads/ { location ^~ /uploads/ {
proxy_pass http://family-wishlist-backend:3000/uploads/; proxy_pass http://family-wishlist-backend:3000/uploads/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_cache_valid 200 1h; proxy_cache_valid 200 1h;
} }
# Static files with long cache
location ~* \.(?:js|css|woff2?|svg|png|jpg|jpeg|gif|webp|ico)$ {
expires 7d;
add_header Cache-Control "public";
try_files $uri =404;
}
# SPA fallback # SPA fallback
location / { location / {
try_files $uri /index.html; try_files $uri /index.html;