Compare commits
5 Commits
d46d4c4487
...
fix/header
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbf1d2d02f | ||
| 0ada42017f | |||
|
|
1bb0d0814c | ||
| 55736f2ea3 | |||
|
|
14a57b19b7 |
@@ -50,7 +50,7 @@ export function Header() {
|
|||||||
<span className="app-header__brand-mark">
|
<span className="app-header__brand-mark">
|
||||||
<Gift className="h-4 w-4" />
|
<Gift className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="app-header__brand-title">{t('app.name')}</div>
|
<div className="app-header__brand-title">{t('app.name')}</div>
|
||||||
<div className="app-header__brand-subtitle">
|
<div className="app-header__brand-subtitle">
|
||||||
{t('header.signedInAs', { name: user.displayName })}
|
{t('header.signedInAs', { name: user.displayName })}
|
||||||
@@ -96,21 +96,26 @@ export function Header() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="app-header__action"
|
||||||
onClick={() => navigate('/settings')}
|
onClick={() => navigate('/settings')}
|
||||||
title={t('header.profileSettings')}
|
title={t('header.profileSettings')}
|
||||||
|
aria-label={t('header.profileSettings')}
|
||||||
>
|
>
|
||||||
<UserCog className="h-4 w-4" />
|
<UserCog className="h-4 w-4" />
|
||||||
{t('header.profile')}
|
<span className="app-header__action-text">{t('header.profile')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="app-header__action"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void logout().then(() => navigate('/login'));
|
void logout().then(() => navigate('/login'));
|
||||||
}}
|
}}
|
||||||
|
title={t('header.logout')}
|
||||||
|
aria-label={t('header.logout')}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
{t('header.logout')}
|
<span className="app-header__action-text">{t('header.logout')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const translations = {
|
|||||||
'public.loadingWishes': 'Загрузка желаний...',
|
'public.loadingWishes': 'Загрузка желаний...',
|
||||||
'public.notFoundTitle': 'Профиль не найден',
|
'public.notFoundTitle': 'Профиль не найден',
|
||||||
'public.notFoundText': 'Проверьте ссылку и попробуйте ещё раз. Slug чувствителен к регистру.',
|
'public.notFoundText': 'Проверьте ссылку и попробуйте ещё раз. Slug чувствителен к регистру.',
|
||||||
|
'public.backToMine': 'Вернуться к моим желаниям',
|
||||||
'public.wishlistTitle': 'Список желаний {name}',
|
'public.wishlistTitle': 'Список желаний {name}',
|
||||||
'public.emptyTitle': 'Желаний пока нет',
|
'public.emptyTitle': 'Желаний пока нет',
|
||||||
'public.emptyText': 'Загляните позже!',
|
'public.emptyText': 'Загляните позже!',
|
||||||
@@ -196,6 +197,7 @@ const translations = {
|
|||||||
'public.loadingWishes': 'Loading wishes...',
|
'public.loadingWishes': 'Loading wishes...',
|
||||||
'public.notFoundTitle': 'Profile not found',
|
'public.notFoundTitle': 'Profile not found',
|
||||||
'public.notFoundText': 'Check the link and try again. Slugs are case-sensitive.',
|
'public.notFoundText': 'Check the link and try again. Slugs are case-sensitive.',
|
||||||
|
'public.backToMine': 'Back to my wishlist',
|
||||||
'public.wishlistTitle': "{name}'s wishlist",
|
'public.wishlistTitle': "{name}'s wishlist",
|
||||||
'public.emptyTitle': 'No wishes yet',
|
'public.emptyTitle': 'No wishes yet',
|
||||||
'public.emptyText': 'Check back later!',
|
'public.emptyText': 'Check back later!',
|
||||||
|
|||||||
@@ -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,6 +121,10 @@ 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 className="page-section">
|
<section className="page-section">
|
||||||
@@ -136,8 +141,13 @@ export function ProfileSettingsPage() {
|
|||||||
<form className="profile-form" onSubmit={submit}>
|
<form className="profile-form" onSubmit={submit}>
|
||||||
<section className="profile-form__avatar-panel">
|
<section className="profile-form__avatar-panel">
|
||||||
<span className="profile-form__avatar-preview">
|
<span className="profile-form__avatar-preview">
|
||||||
{avatarPreview ? (
|
{avatarPreview && !avatarFailed ? (
|
||||||
<img src={avatarPreview} alt="" className="profile-form__avatar-image" />
|
<img
|
||||||
|
src={avatarPreview}
|
||||||
|
alt=""
|
||||||
|
className="profile-form__avatar-image"
|
||||||
|
onError={() => setAvatarFailed(true)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Gift className="h-6 w-6" />
|
<Gift className="h-6 w-6" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { 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 {
|
||||||
PublicProfile,
|
PublicProfile,
|
||||||
@@ -11,11 +11,15 @@ import { Footer } from '@/components/Layout/Footer';
|
|||||||
import { Gift } from 'lucide-react';
|
import { Gift } from 'lucide-react';
|
||||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||||
import { useI18n } from '@/i18n/i18n';
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
import { useAuthStore } from '@/features/auth/authStore';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
export function PublicProfilePage() {
|
export function PublicProfilePage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
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],
|
||||||
@@ -54,9 +58,22 @@ 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="public-profile">
|
<div className="public-profile">
|
||||||
<div className="public-profile__toolbar">
|
<div className="public-profile__toolbar">
|
||||||
|
{user ? (
|
||||||
|
<Link to="/">
|
||||||
|
<Button variant="secondary" size="sm">
|
||||||
|
{t('public.backToMine')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<main className="public-profile__main">
|
<main className="public-profile__main">
|
||||||
@@ -75,11 +92,12 @@ export function PublicProfilePage() {
|
|||||||
<>
|
<>
|
||||||
<section className="public-profile__hero">
|
<section className="public-profile__hero">
|
||||||
<span className="public-profile__avatar">
|
<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="public-profile__avatar-image"
|
className="public-profile__avatar-image"
|
||||||
|
onError={() => setAvatarFailed(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Gift className="h-6 w-6" />
|
<Gift className="h-6 w-6" />
|
||||||
|
|||||||
@@ -40,10 +40,10 @@
|
|||||||
@apply mx-auto w-full max-w-6xl px-4 pt-6 sm:px-6 lg:px-8;
|
@apply mx-auto w-full max-w-6xl px-4 pt-6 sm:px-6 lg:px-8;
|
||||||
}
|
}
|
||||||
.app-header__inner {
|
.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;
|
@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 {
|
.app-header__brand {
|
||||||
@apply flex items-center gap-2;
|
@apply flex min-w-0 items-center gap-2;
|
||||||
}
|
}
|
||||||
.app-header__brand-mark {
|
.app-header__brand-mark {
|
||||||
@apply inline-flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card;
|
@apply inline-flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card;
|
||||||
@@ -52,10 +52,10 @@
|
|||||||
@apply font-display text-lg leading-tight;
|
@apply font-display text-lg leading-tight;
|
||||||
}
|
}
|
||||||
.app-header__brand-subtitle {
|
.app-header__brand-subtitle {
|
||||||
@apply text-xs text-muted;
|
@apply truncate text-xs text-muted;
|
||||||
}
|
}
|
||||||
.app-header__nav {
|
.app-header__nav {
|
||||||
@apply flex flex-wrap items-center gap-1;
|
@apply flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap lg:justify-center;
|
||||||
}
|
}
|
||||||
.app-header__nav-link {
|
.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;
|
@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;
|
||||||
@@ -64,7 +64,13 @@
|
|||||||
@apply bg-primary text-primary-foreground shadow-card hover:bg-primary;
|
@apply bg-primary text-primary-foreground shadow-card hover:bg-primary;
|
||||||
}
|
}
|
||||||
.app-header__actions {
|
.app-header__actions {
|
||||||
@apply flex items-center gap-1;
|
@apply flex shrink-0 items-center gap-1 justify-self-start whitespace-nowrap lg:justify-self-end;
|
||||||
|
}
|
||||||
|
.app-header__action {
|
||||||
|
@apply shrink-0;
|
||||||
|
}
|
||||||
|
.app-header__action-text {
|
||||||
|
@apply hidden xl:inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-footer {
|
.app-footer {
|
||||||
@@ -129,7 +135,7 @@
|
|||||||
@apply flex min-h-screen flex-col;
|
@apply flex min-h-screen flex-col;
|
||||||
}
|
}
|
||||||
.public-profile__toolbar {
|
.public-profile__toolbar {
|
||||||
@apply mx-auto flex w-full max-w-6xl justify-end px-4 pt-6 sm:px-6 lg:px-8;
|
@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 {
|
.public-profile__main {
|
||||||
@apply mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6 lg:px-8;
|
@apply mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6 lg:px-8;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user