4 Commits

5 changed files with 72 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ import { updateProfileSchema } from '@family-wishlist/shared';
import { ConflictError, NotFoundError, ValidationError } from '../../utils/errors.js'; import { ConflictError, NotFoundError, ValidationError } from '../../utils/errors.js';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { deleteLocalImageIfAny, saveUploadedAvatar } from '../images/storage.service.js'; import { deleteLocalImageIfAny, saveUploadedAvatar } from '../images/storage.service.js';
import { usersRegistry } from '../../auth/users.registry.js';
const MAX_AVATAR_BYTES = 2 * 1024 * 1024; const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
@@ -15,6 +16,16 @@ export default async function profileRoutes(app: FastifyInstance) {
return profile; 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) => { app.patch('/', async (request) => {
const body = updateProfileSchema.parse(request.body); const body = updateProfileSchema.parse(request.body);
try { try {

View File

@@ -1,14 +1,22 @@
import { Link, NavLink, useNavigate } from 'react-router-dom'; 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 type { ComponentType } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '../ui/Button'; import { Button } from '../ui/Button';
import { useAuthStore } from '@/features/auth/authStore'; import { useAuthStore } from '@/features/auth/authStore';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { useI18n, type TranslationKey } from '@/i18n/i18n'; import { useI18n, type TranslationKey } from '@/i18n/i18n';
import { LanguageSwitcher } from '../LanguageSwitcher'; import { LanguageSwitcher } from '../LanguageSwitcher';
import { api } from '@/lib/api';
type NavIcon = ComponentType<{ className?: string }>; type NavIcon = ComponentType<{ className?: string }>;
interface FriendProfile {
slug: string;
displayName: string;
avatarUrl: string | null;
}
const links = [ const links = [
{ to: '/', label: 'header.active', icon: Sparkles, end: true }, { to: '/', label: 'header.active', icon: Sparkles, end: true },
{ to: '/completed', label: 'header.fulfilled', icon: CheckCircle2 }, { to: '/completed', label: 'header.fulfilled', icon: CheckCircle2 },
@@ -26,25 +34,31 @@ export function Header() {
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n(); 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; if (!user) return null;
return ( return (
<header className="container-page pt-6"> <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"> <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 items-center gap-2"> <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"> <span className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
<Gift className="h-4 w-4" /> <Gift className="h-4 w-4" />
</span> </span>
<div> <div className="min-w-0">
<div className="font-display text-lg leading-tight">{t('app.name')}</div> <div className="font-display text-lg leading-tight">{t('app.name')}</div>
<div className="text-xs text-muted"> <div className="truncate text-xs text-muted">
{t('header.signedInAs', { name: user.displayName })} {t('header.signedInAs', { name: user.displayName })}
</div> </div>
</div> </div>
</Link> </Link>
<nav className="flex flex-wrap items-center gap-1"> <nav className="flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap lg:justify-center">
{links.map((l) => ( {links.map((l) => (
<NavLink <NavLink
key={l.to} key={l.to}
@@ -61,9 +75,23 @@ export function Header() {
{t(l.label)} {t(l.label)}
</NavLink> </NavLink>
))} ))}
{friend.data && (
<NavLink
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',
)
}
>
<Users className="h-4 w-4" />
{t('header.friendWishes', { name: friend.data.displayName })}
</NavLink>
)}
</nav> </nav>
<div className="flex items-center gap-1"> <div className="flex shrink-0 items-center gap-1 justify-self-start lg:justify-self-end">
<LanguageSwitcher /> <LanguageSwitcher />
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -36,6 +36,7 @@ const translations = {
'header.signedInAs': 'вошли как {name}', 'header.signedInAs': 'вошли как {name}',
'header.profileSettings': 'Настройки профиля', 'header.profileSettings': 'Настройки профиля',
'header.profile': 'Профиль', 'header.profile': 'Профиль',
'header.friendWishes': 'Желания {name}',
'header.logout': 'Выйти', 'header.logout': 'Выйти',
'login.title': 'С возвращением', 'login.title': 'С возвращением',
'login.description': 'Войдите, чтобы управлять списком желаний. Доступы задаются на сервере.', 'login.description': 'Войдите, чтобы управлять списком желаний. Доступы задаются на сервере.',
@@ -79,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': 'Загляните позже!',
@@ -151,6 +153,7 @@ const translations = {
'header.signedInAs': 'signed in as {name}', 'header.signedInAs': 'signed in as {name}',
'header.profileSettings': 'Profile settings', 'header.profileSettings': 'Profile settings',
'header.profile': 'Profile', 'header.profile': 'Profile',
'header.friendWishes': "{name}'s wishes",
'header.logout': 'Log out', 'header.logout': 'Log out',
'login.title': 'Welcome back', 'login.title': 'Welcome back',
'login.description': 'Sign in to manage your wishlist. Credentials are set up via the server environment.', '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.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!',

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'; 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { import type {
PublicProfile, PublicProfile,
@@ -11,9 +11,12 @@ 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();
@@ -56,7 +59,16 @@ export function PublicProfilePage() {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<div className="container-page flex justify-end pt-6"> <div className="container-page flex items-center justify-between gap-3 pt-6">
{user ? (
<Link to="/">
<Button variant="secondary" size="sm">
{t('public.backToMine')}
</Button>
</Link>
) : (
<span />
)}
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
<main className="container-page flex-1 py-10"> <main className="container-page flex-1 py-10">

View File

@@ -15,6 +15,14 @@ export const profileSchema = z.object({
export type Profile = z.infer<typeof profileSchema>; 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({ export const updateProfileSchema = z.object({
slug: z slug: z
.string() .string()