7 Commits

9 changed files with 82 additions and 55 deletions

View File

@@ -227,7 +227,7 @@ All responses are JSON. Authenticated endpoints require the `fw_auth` cookie.
Each package has its own version (`apps/backend/package.json` and `apps/frontend/package.json`). The app footer shows both: Each package has its own version (`apps/backend/package.json` and `apps/frontend/package.json`). The app footer shows both:
``` ```
frontend v0.1.0 · backend v0.1.0 frontend v0.3.4 · backend v0.3.0
``` ```
Bump them per semver on each change: Bump them per semver on each change:

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-wishlist/backend", "name": "@family-wishlist/backend",
"version": "0.1.0", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-wishlist/frontend", "name": "@family-wishlist/frontend",
"version": "0.1.0", "version": "0.3.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -58,6 +58,35 @@ export function Header() {
</div> </div>
</Link> </Link>
<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"> <nav className="app-header__nav">
{links.map((l) => ( {links.map((l) => (
<NavLink <NavLink
@@ -90,30 +119,6 @@ export function Header() {
</NavLink> </NavLink>
)} )}
</nav> </nav>
<div className="app-header__actions">
<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

@@ -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" />
)} )}

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,6 +58,10 @@ 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">
@@ -87,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" />

View File

@@ -40,7 +40,7 @@
@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 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]; @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 { .app-header__brand {
@apply flex min-w-0 items-center gap-2; @apply flex min-w-0 items-center gap-2;
@@ -55,7 +55,7 @@
@apply truncate text-xs text-muted; @apply truncate text-xs text-muted;
} }
.app-header__nav { .app-header__nav {
@apply flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap lg:justify-center; @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 { .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 shrink-0 items-center gap-1 justify-self-start lg:justify-self-end; @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 { .app-footer {

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;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@family-wishlist/shared", "name": "@family-wishlist/shared",
"version": "0.1.0", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",