7 Commits

15 changed files with 342 additions and 143 deletions

View File

@@ -13,22 +13,20 @@ export function LanguageSwitcher({ className }: { className?: string }) {
return (
<div
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,
)}
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) => (
<button
key={item.value}
type="button"
onClick={() => setLanguage(item.value)}
className={cn(
'rounded px-2 py-1 font-semibold transition-colors',
language === item.value
? 'bg-primary text-primary-foreground'
: 'text-muted hover:bg-surface-muted hover:text-ink',
'language-switcher__button',
language === item.value && 'language-switcher__button--active',
)}
aria-pressed={language === item.value}
title={item.value === 'ru' ? t('language.ru') : t('language.en')}

View File

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

View File

@@ -17,15 +17,15 @@ export function Footer() {
});
return (
<footer className="container-page mt-10 py-6 text-xs text-muted">
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row">
<div className="flex items-center gap-2">
<footer className="app-footer">
<div className="app-footer__inner">
<div className="app-footer__brand">
<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 className="flex items-center gap-3">
<div className="app-footer__meta">
<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>
</div>
</div>

View File

@@ -44,21 +44,50 @@ export function Header() {
if (!user) return null;
return (
<header className="container-page pt-6">
<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]">
<Link to="/" className="flex min-w-0 items-center gap-2">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
<header className="app-header">
<div className="app-header__inner">
<Link to="/" className="app-header__brand">
<span className="app-header__brand-mark">
<Gift className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="font-display text-lg leading-tight">{t('app.name')}</div>
<div className="truncate text-xs text-muted">
<div className="app-header__brand-title">{t('app.name')}</div>
<div className="app-header__brand-subtitle">
{t('header.signedInAs', { name: user.displayName })}
</div>
</div>
</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) => (
<NavLink
key={l.to}
@@ -66,8 +95,8 @@ export function Header() {
end={l.end}
className={({ isActive }) =>
cn(
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
isActive ? 'bg-primary text-primary-foreground shadow-card' : 'text-ink hover:bg-ink/5',
'app-header__nav-link',
isActive && 'app-header__nav-link--active',
)
}
>
@@ -80,8 +109,8 @@ export function Header() {
to={`/u/${friend.data.slug}`}
className={({ isActive }) =>
cn(
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
isActive ? 'bg-primary text-primary-foreground shadow-card' : 'text-ink hover:bg-ink/5',
'app-header__nav-link',
isActive && 'app-header__nav-link--active',
)
}
>
@@ -90,30 +119,6 @@ export function Header() {
</NavLink>
)}
</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>
);
}

View File

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

View File

@@ -15,9 +15,9 @@ export function ArchivePage() {
return (
<div className="grid gap-6">
<section>
<h1 className="font-display text-3xl">{t('archive.title')}</h1>
<p className="text-sm text-muted">
<section className="page-section">
<h1 className="page-section__title">{t('archive.title')}</h1>
<p className="page-section__text">
{t('archive.description')}
</p>
</section>
@@ -25,10 +25,10 @@ export function ArchivePage() {
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!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">
<Archive className="h-10 w-10 text-muted" />
<h2 className="text-xl font-semibold">{t('archive.emptyTitle')}</h2>
<p className="text-sm text-muted">{t('archive.emptyText')}</p>
<div className="empty-state">
<Archive className="empty-state__icon" />
<h2 className="empty-state__title">{t('archive.emptyTitle')}</h2>
<p className="empty-state__text">{t('archive.emptyText')}</p>
</div>
)}

View File

@@ -15,9 +15,9 @@ export function CompletedPage() {
return (
<div className="grid gap-6">
<section>
<h1 className="font-display text-3xl">{t('completed.title')}</h1>
<p className="text-sm text-muted">
<section className="page-section">
<h1 className="page-section__title">{t('completed.title')}</h1>
<p className="page-section__text">
{t('completed.description')}
</p>
</section>
@@ -25,10 +25,10 @@ export function CompletedPage() {
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!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">
<CheckCircle2 className="h-10 w-10 text-muted" />
<h2 className="text-xl font-semibold">{t('completed.emptyTitle')}</h2>
<p className="text-sm text-muted">
<div className="empty-state">
<CheckCircle2 className="empty-state__icon" />
<h2 className="empty-state__title">{t('completed.emptyTitle')}</h2>
<p className="empty-state__text">
{t('completed.emptyText')}
</p>
</div>

View File

@@ -26,10 +26,10 @@ export function DashboardPage() {
return (
<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>
<h1 className="font-display text-3xl">{t('dashboard.title')}</h1>
<p className="text-sm text-muted">
<h1 className="page-section__title">{t('dashboard.title')}</h1>
<p className="page-section__text">
{t('dashboard.description')}
{user && (
<Link
@@ -61,11 +61,11 @@ export function DashboardPage() {
)}
{!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">
<img src="/empty-state.svg" alt="" className="h-40 w-40 opacity-90" />
<div className="empty-state gap-4">
<img src="/empty-state.svg" alt="" className="empty-state__icon--image" />
<div>
<h2 className="text-xl font-semibold">{t('dashboard.emptyTitle')}</h2>
<p className="mt-1 text-sm text-muted">
<h2 className="empty-state__title">{t('dashboard.emptyTitle')}</h2>
<p className="empty-state__text mt-1">
{t('dashboard.emptyText')}
</p>
</div>

View File

@@ -47,11 +47,11 @@ export function LoginPage() {
});
return (
<div className="flex min-h-screen flex-col">
<div className="container-page flex justify-end pt-6">
<div className="app-shell">
<div className="public-profile__toolbar">
<LanguageSwitcher />
</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="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">
@@ -60,7 +60,7 @@ export function LoginPage() {
<h1 className="font-display text-3xl">{t('app.name')}</h1>
</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>
<p className="mb-6 text-sm text-muted">
{t('login.description')}

View File

@@ -5,8 +5,8 @@ import { useI18n } from '@/i18n/i18n';
export function NotFoundPage() {
const { t } = useI18n();
return (
<div className="flex min-h-screen 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="app-shell items-center justify-center p-6">
<div className="empty-state max-w-md bg-surface p-8">
<h1 className="font-display text-4xl">404</h1>
<p className="mt-2 text-muted">{t('notFound.text')}</p>
<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 { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -25,6 +25,7 @@ export function ProfileSettingsPage() {
const refresh = useAuthStore((s) => s.refresh);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarFailed, setAvatarFailed] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['profile'],
@@ -120,11 +121,15 @@ export function ProfileSettingsPage() {
const avatarPreview = watch('avatarUrl') || data?.avatarUrl;
useEffect(() => {
setAvatarFailed(false);
}, [avatarPreview]);
return (
<div className="grid max-w-2xl gap-6">
<section>
<h1 className="font-display text-3xl">{t('profile.title')}</h1>
<p className="text-sm text-muted">
<section className="page-section">
<h1 className="page-section__title">{t('profile.title')}</h1>
<p className="page-section__text">
{t('profile.publicPage')}
<code className="rounded bg-ink/5 px-1.5 py-0.5">/u/{data?.slug ?? '...'}</code>.
</p>
@@ -133,18 +138,23 @@ export function ProfileSettingsPage() {
{isLoading ? (
<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}>
<section className="flex flex-wrap items-center gap-4 rounded-md border border-border bg-surface-muted p-4">
<span className="inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-primary text-primary-foreground">
{avatarPreview ? (
<img src={avatarPreview} alt="" className="h-16 w-16 object-cover" />
<form className="profile-form" onSubmit={submit}>
<section className="profile-form__avatar-panel">
<span className="profile-form__avatar-preview">
{avatarPreview && !avatarFailed ? (
<img
src={avatarPreview}
alt=""
className="profile-form__avatar-image"
onError={() => setAvatarFailed(true)}
/>
) : (
<Gift className="h-6 w-6" />
)}
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">{t('profile.avatar')}</h2>
<p className="text-xs text-muted">{t('profile.avatarHint')}</p>
<div className="profile-form__avatar-copy">
<h2 className="profile-form__avatar-title">{t('profile.avatar')}</h2>
<p className="profile-form__avatar-hint">{t('profile.avatarHint')}</p>
</div>
<input
ref={fileInputRef}
@@ -199,7 +209,7 @@ export function ProfileSettingsPage() {
</span>
)}
</div>
<div className="flex items-center justify-end gap-2">
<div className="profile-form__actions">
<Button type="submit" disabled={!isDirty || isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
{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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
@@ -19,6 +19,7 @@ export function PublicProfilePage() {
const user = useAuthStore((s) => s.user);
const { slug = '' } = useParams<{ slug: string }>();
const queryClient = useQueryClient();
const [avatarFailed, setAvatarFailed] = useState(false);
const profile = useQuery({
queryKey: ['public-profile', slug],
@@ -57,9 +58,13 @@ export function PublicProfilePage() {
return () => window.clearTimeout(t);
}, [wishes.data, markSeen, queryClient, slug]);
useEffect(() => {
setAvatarFailed(false);
}, [profile.data?.avatarUrl]);
return (
<div className="flex min-h-screen flex-col">
<div className="container-page flex items-center justify-between gap-3 pt-6">
<div className="public-profile">
<div className="public-profile__toolbar">
{user ? (
<Link to="/">
<Button variant="secondary" size="sm">
@@ -71,13 +76,13 @@ export function PublicProfilePage() {
)}
<LanguageSwitcher />
</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.isError && (
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface p-8 text-center shadow-card">
<h1 className="font-display text-2xl">{t('public.notFoundTitle')}</h1>
<p className="mt-2 text-sm text-muted">
<div className="empty-state mx-auto max-w-lg bg-surface p-8">
<h1 className="empty-state__title">{t('public.notFoundTitle')}</h1>
<p className="empty-state__text mt-2">
{t('public.notFoundText')}
</p>
</div>
@@ -85,32 +90,33 @@ export function PublicProfilePage() {
{profile.data && (
<>
<section className="mb-10 flex flex-col items-center gap-3 text-center">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-card">
{profile.data.avatarUrl ? (
<section className="public-profile__hero">
<span className="public-profile__avatar">
{profile.data.avatarUrl && !avatarFailed ? (
<img
src={profile.data.avatarUrl}
alt=""
className="h-14 w-14 rounded-full object-cover"
className="public-profile__avatar-image"
onError={() => setAvatarFailed(true)}
/>
) : (
<Gift className="h-6 w-6" />
)}
</span>
<h1 className="font-display text-4xl">
<h1 className="public-profile__title">
{t('public.wishlistTitle', { name: profile.data.displayName })}
</h1>
{profile.data.bio && (
<p className="max-w-xl text-muted">{profile.data.bio}</p>
<p className="public-profile__bio">{profile.data.bio}</p>
)}
</section>
{wishes.isLoading && <div className="text-muted">{t('public.loadingWishes')}</div>}
{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">
<h2 className="text-xl font-semibold">{t('public.emptyTitle')}</h2>
<p className="mt-1 text-sm text-muted">{t('public.emptyText')}</p>
<div className="empty-state mx-auto max-w-lg">
<h2 className="empty-state__title">{t('public.emptyTitle')}</h2>
<p className="empty-state__text mt-1">{t('public.emptyText')}</p>
</div>
)}

View File

@@ -12,9 +12,9 @@ export function TrashPage() {
return (
<div className="grid gap-6">
<section>
<h1 className="font-display text-3xl">{t('trash.title')}</h1>
<p className="text-sm text-muted">
<section className="page-section">
<h1 className="page-section__title">{t('trash.title')}</h1>
<p className="page-section__text">
{t('trash.description', { days: dayCount(TRASH_RETENTION_DAYS) })}
</p>
</section>
@@ -22,10 +22,10 @@ export function TrashPage() {
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!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">
<Trash2 className="h-10 w-10 text-muted" />
<h2 className="text-xl font-semibold">{t('trash.emptyTitle')}</h2>
<p className="text-sm text-muted">
<div className="empty-state">
<Trash2 className="empty-state__icon" />
<h2 className="empty-state__title">{t('trash.emptyTitle')}</h2>
<p className="empty-state__text">
{t('trash.emptyText', { days: dayCount(TRASH_RETENTION_DAYS) })}
</p>
</div>

View File

@@ -29,6 +29,192 @@
@layer components {
/* 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 {
@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;
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
location /api/ {
proxy_pass http://family-wishlist-backend:3000/api/;
@@ -24,13 +17,20 @@ server {
}
# Uploaded files (images)
location /uploads/ {
location ^~ /uploads/ {
proxy_pass http://family-wishlist-backend:3000/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
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
location / {
try_files $uri /index.html;