Compare commits
7 Commits
feature/i1
...
refactor/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb0d0814c | ||
|
|
d46d4c4487 | ||
| 55736f2ea3 | |||
|
|
14a57b19b7 | ||
| 17d59c3639 | |||
|
|
34179b3f30 | ||
| f8fcda0d13 |
@@ -3,6 +3,7 @@ import { updateProfileSchema } from '@family-wishlist/shared';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '../../utils/errors.js';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { deleteLocalImageIfAny, saveUploadedAvatar } from '../images/storage.service.js';
|
||||
import { usersRegistry } from '../../auth/users.registry.js';
|
||||
|
||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
@@ -15,6 +16,16 @@ export default async function profileRoutes(app: FastifyInstance) {
|
||||
return profile;
|
||||
});
|
||||
|
||||
app.get('/friend', async (request) => {
|
||||
const friend = usersRegistry.all().find((u) => u.id !== request.user.id);
|
||||
if (!friend) return null;
|
||||
|
||||
return app.prisma.user.findUnique({
|
||||
where: { id: friend.id },
|
||||
select: { slug: true, displayName: true, avatarUrl: true },
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/', async (request) => {
|
||||
const body = updateProfileSchema.parse(request.body);
|
||||
try {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { Archive, Gift, LogOut, Sparkles, Trash2, UserCog, CheckCircle2 } from 'lucide-react';
|
||||
import { Archive, CheckCircle2, Gift, LogOut, Sparkles, Trash2, UserCog, Users } from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '../ui/Button';
|
||||
import { useAuthStore } from '@/features/auth/authStore';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useI18n, type TranslationKey } from '@/i18n/i18n';
|
||||
import { LanguageSwitcher } from '../LanguageSwitcher';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
type NavIcon = ComponentType<{ className?: string }>;
|
||||
|
||||
interface FriendProfile {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
const links = [
|
||||
{ to: '/', label: 'header.active', icon: Sparkles, end: true },
|
||||
{ to: '/completed', label: 'header.fulfilled', icon: CheckCircle2 },
|
||||
@@ -26,25 +34,31 @@ export function Header() {
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const friend = useQuery({
|
||||
queryKey: ['profile-friend'],
|
||||
queryFn: () => api.get<FriendProfile | null>('/api/profile/friend'),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
enabled: user != null,
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<header className="container-page pt-6">
|
||||
<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">
|
||||
<Link to="/" className="flex 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>
|
||||
<div className="font-display text-lg leading-tight">{t('app.name')}</div>
|
||||
<div className="text-xs text-muted">
|
||||
<div className="min-w-0">
|
||||
<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 flex-wrap items-center gap-1">
|
||||
<nav className="app-header__nav">
|
||||
{links.map((l) => (
|
||||
<NavLink
|
||||
key={l.to}
|
||||
@@ -52,8 +66,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',
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -61,9 +75,23 @@ export function Header() {
|
||||
{t(l.label)}
|
||||
</NavLink>
|
||||
))}
|
||||
{friend.data && (
|
||||
<NavLink
|
||||
to={`/u/${friend.data.slug}`}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'app-header__nav-link',
|
||||
isActive && 'app-header__nav-link--active',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
{t('header.friendWishes', { name: friend.data.displayName })}
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="app-header__actions">
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -36,6 +36,7 @@ const translations = {
|
||||
'header.signedInAs': 'вошли как {name}',
|
||||
'header.profileSettings': 'Настройки профиля',
|
||||
'header.profile': 'Профиль',
|
||||
'header.friendWishes': 'Желания {name}',
|
||||
'header.logout': 'Выйти',
|
||||
'login.title': 'С возвращением',
|
||||
'login.description': 'Войдите, чтобы управлять списком желаний. Доступы задаются на сервере.',
|
||||
@@ -79,6 +80,7 @@ const translations = {
|
||||
'public.loadingWishes': 'Загрузка желаний...',
|
||||
'public.notFoundTitle': 'Профиль не найден',
|
||||
'public.notFoundText': 'Проверьте ссылку и попробуйте ещё раз. Slug чувствителен к регистру.',
|
||||
'public.backToMine': 'Вернуться к моим желаниям',
|
||||
'public.wishlistTitle': 'Список желаний {name}',
|
||||
'public.emptyTitle': 'Желаний пока нет',
|
||||
'public.emptyText': 'Загляните позже!',
|
||||
@@ -151,6 +153,7 @@ const translations = {
|
||||
'header.signedInAs': 'signed in as {name}',
|
||||
'header.profileSettings': 'Profile settings',
|
||||
'header.profile': 'Profile',
|
||||
'header.friendWishes': "{name}'s wishes",
|
||||
'header.logout': 'Log out',
|
||||
'login.title': 'Welcome back',
|
||||
'login.description': 'Sign in to manage your wishlist. Credentials are set up via the server environment.',
|
||||
@@ -194,6 +197,7 @@ const translations = {
|
||||
'public.loadingWishes': 'Loading wishes...',
|
||||
'public.notFoundTitle': 'Profile not found',
|
||||
'public.notFoundText': 'Check the link and try again. Slugs are case-sensitive.',
|
||||
'public.backToMine': 'Back to my wishlist',
|
||||
'public.wishlistTitle': "{name}'s wishlist",
|
||||
'public.emptyTitle': 'No wishes yet',
|
||||
'public.emptyText': 'Check back later!',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -122,9 +122,9 @@ export function ProfileSettingsPage() {
|
||||
|
||||
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 +133,18 @@ 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">
|
||||
<form className="profile-form" onSubmit={submit}>
|
||||
<section className="profile-form__avatar-panel">
|
||||
<span className="profile-form__avatar-preview">
|
||||
{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" />
|
||||
)}
|
||||
</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 +199,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')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
PublicProfile,
|
||||
@@ -11,9 +11,12 @@ import { Footer } from '@/components/Layout/Footer';
|
||||
import { Gift } from 'lucide-react';
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { useI18n } from '@/i18n/i18n';
|
||||
import { useAuthStore } from '@/features/auth/authStore';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export function PublicProfilePage() {
|
||||
const { t } = useI18n();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { slug = '' } = useParams<{ slug: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -55,17 +58,26 @@ export function PublicProfilePage() {
|
||||
}, [wishes.data, markSeen, queryClient, slug]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<div className="container-page flex justify-end pt-6">
|
||||
<div className="public-profile">
|
||||
<div className="public-profile__toolbar">
|
||||
{user ? (
|
||||
<Link to="/">
|
||||
<Button variant="secondary" size="sm">
|
||||
{t('public.backToMine')}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<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>
|
||||
@@ -73,32 +85,32 @@ 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">
|
||||
<section className="public-profile__hero">
|
||||
<span className="public-profile__avatar">
|
||||
{profile.data.avatarUrl ? (
|
||||
<img
|
||||
src={profile.data.avatarUrl}
|
||||
alt=""
|
||||
className="h-14 w-14 rounded-full object-cover"
|
||||
className="public-profile__avatar-image"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,6 +29,186 @@
|
||||
|
||||
@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 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 {
|
||||
@apply relative flex flex-col overflow-hidden rounded-lg bg-surface shadow-card transition-transform duration-200;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ export const profileSchema = z.object({
|
||||
|
||||
export type Profile = z.infer<typeof profileSchema>;
|
||||
|
||||
export const friendProfileSchema = z.object({
|
||||
slug: z.string(),
|
||||
displayName: z.string(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type FriendProfile = z.infer<typeof friendProfileSchema>;
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
|
||||
Reference in New Issue
Block a user