34 Commits

Author SHA1 Message Date
26cf978e3c Merge pull request 'chore: fix package versions' (#17) from chore/fix-package-versions into main
Reviewed-on: #17
2026-04-27 19:54:48 +00:00
Vaka.pro
dbffefc26f chore: fix package versions 2026-04-27 22:54:06 +03:00
5ab8ef3ba8 Merge pull request 'fix: move header nav to separate row' (#16) from fix/header-nav-separate-row into main
Reviewed-on: #16
2026-04-27 18:44:52 +00:00
Vaka.pro
235b40b326 fix: move header nav to separate row 2026-04-27 21:44:09 +03:00
13b3b1a257 Merge pull request 'fix: prevent header wrapping and uploaded image 404s' (#15) from fix/header-actions-and-uploaded-images into main
Reviewed-on: #15
2026-04-27 18:31:07 +00:00
Vaka.pro
fbf1d2d02f fix: prevent header wrapping and uploaded image 404s 2026-04-27 21:29:47 +03:00
0ada42017f Merge pull request 'refactor(frontend): move repeated Tailwind chains into BEM classes' (#14) from refactor/frontend-bem-classes into main
Reviewed-on: #14
2026-04-27 17:49:30 +00:00
Vaka.pro
1bb0d0814c Merge remote-tracking branch 'origin/main' into refactor/frontend-bem-classes
# Conflicts:
#	apps/frontend/src/components/Layout/Header.tsx
#	apps/frontend/src/pages/PublicProfilePage.tsx
2026-04-27 20:47:08 +03:00
Vaka.pro
d46d4c4487 refactor(frontend): move repeated Tailwind chains into BEM classes 2026-04-27 20:42:21 +03:00
55736f2ea3 Merge pull request 'fix: improve friend wishlist navigation layout' (#13) from fix/friend-link-layout into main
Reviewed-on: #13
2026-04-26 20:46:07 +00:00
Vaka.pro
14a57b19b7 fix: improve friend wishlist navigation layout 2026-04-26 23:44:06 +03:00
17d59c3639 Merge pull request 'feat: add friend wishlist link' (#12) from feature/friend-wishlist-link into main
Reviewed-on: #12
2026-04-26 20:30:23 +00:00
Vaka.pro
34179b3f30 feat: add friend wishlist link 2026-04-26 23:29:20 +03:00
f8fcda0d13 Merge pull request 'feat: add i18n and avatar upload' (#11) from feature/i18n-avatar-upload into main
Reviewed-on: #11
2026-04-26 19:18:14 +00:00
Vaka.pro
1b23097b18 feat: add i18n and avatar upload 2026-04-26 22:16:59 +03:00
db41d4a246 Merge pull request 'chore: update frontend port mapping' (#10) from chore/update-frontend-port into main
Reviewed-on: #10
2026-04-26 18:52:25 +00:00
Vaka.pro
547a452097 chore: update frontend port mapping 2026-04-26 21:50:55 +03:00
3d7501f028 Merge pull request 'fix: build shared package for runtime' (#9) from fix/shared-runtime-build into main
Reviewed-on: #9
2026-04-26 18:42:52 +00:00
Vaka.pro
7c658706ea fix: build shared package for runtime 2026-04-26 21:42:08 +03:00
1c9c21d5a7 Merge pull request 'fix: run compiled seed in backend container' (#8) from fix/backend-runtime-seed into main
Reviewed-on: #8
2026-04-26 17:54:18 +00:00
Vaka.pro
89f75e6d40 fix: run compiled seed in backend container 2026-04-26 20:53:52 +03:00
e69f53114d Merge pull request 'fix: add portable router type annotation' (#7) from fix/frontend-router-type into main
Reviewed-on: #7
2026-04-26 17:40:31 +00:00
Vaka.pro
793f0c3422 fix: add portable router type annotation 2026-04-26 20:39:38 +03:00
d99002dc3c Merge pull request 'fix: resolve backend docker build errors' (#6) from fix/backend-docker-build into main
Reviewed-on: #6
2026-04-26 16:07:36 +00:00
Vaka.pro
c49abafc61 fix: resolve backend docker build errors 2026-04-26 19:06:50 +03:00
4f4f9ff998 Merge pull request 'chore: use shared postgres for family wishlist' (#5) from chore/shared-postgres-db-family into main
Reviewed-on: #5
2026-04-25 14:26:12 +00:00
Vaka.pro
2adb03ff33 chore: use shared postgres for family wishlist 2026-04-25 17:25:28 +03:00
a7d5260ce3 Merge pull request 'docs: add setup, security, and api usage guide' (#4) from docs/readme into main
Reviewed-on: #4
2026-04-23 13:11:42 +00:00
1a978ca98d Merge pull request 'feat(backend): add fastify api, auth, prisma schema and jobs' (#3) from feat/backend into main
Reviewed-on: #3
2026-04-23 13:11:35 +00:00
1e228f7be6 Merge pull request 'feat(frontend): add react spa with wishlist flows and public profile' (#2) from feat/frontend into main
Reviewed-on: #2
2026-04-23 13:11:27 +00:00
1634a3ac27 Merge pull request 'chore(docker): add containerized deployment and nginx proxy' (#1) from chore/docker-files into main
Reviewed-on: #1
2026-04-23 13:11:16 +00:00
Anton
43f52c3475 docs: add setup, security, and api usage guide 2026-04-23 16:07:09 +03:00
Anton
d84b9b5ee7 chore(docker): add containerized deployment and nginx proxy 2026-04-23 16:06:07 +03:00
Anton
2972090c48 feat(backend): add fastify api, auth, prisma schema and jobs 2026-04-23 16:04:44 +03:00
69 changed files with 6522 additions and 248 deletions

View File

@@ -1,12 +1,14 @@
# ========================================== # ==========================================
# Database # Database
# ========================================== # ==========================================
POSTGRES_USER=wishlist DB_HOST=postgres_budget
POSTGRES_PASSWORD=change_me DB_PORT=5432
POSTGRES_DB=family_wishlist DB_NAME=db_family
# DATABASE_URL uses the docker-compose service name `postgres`. DB_USER=
# When running backend outside docker against docker-postgres use localhost:5432. DB_PASSWORD=
DATABASE_URL=postgresql://wishlist:change_me@postgres:5432/family_wishlist # Fill DATABASE_URL explicitly; .env files do not expand ${...} automatically for the app.
# For local host-based development, point it to localhost:5432 instead of postgres_budget.
DATABASE_URL=postgresql://<db_user>:<db_password>@postgres_budget:5432/db_family
# ========================================== # ==========================================
# Users (two fixed accounts) # Users (two fixed accounts)

248
README.md Normal file
View File

@@ -0,0 +1,248 @@
# Family Wishlist
A small, private wishlist app for two users. Each user has their own profile, slug, and independent wishlist. Guests see a public page at `/u/<slug>` and can view active and fulfilled wishes.
- **Backend**: Node.js 20, Fastify 4, Prisma 5, PostgreSQL 16
- **Frontend**: React 18, Vite 5, Tailwind CSS, TanStack Query, React Hook Form + Zod
- **Monorepo**: pnpm workspaces
- **Deploy**: Docker Compose (shared Postgres + backend + nginx-served frontend)
---
## Features
- Two fixed accounts (credentials in env), password stored only as a **bcrypt hash**.
- Wish entity: title, price, currency, link, comment, image (auto-fetched from link or uploaded).
- Statuses: **Active → Archive / Fulfilled → Trash** (kept for 30 days, then permanently removed by a daily cron job).
- Restore from archive or trash. Duplicate from fulfilled into a new active wish.
- Badges on cards:
- owner: `new` for wishes under 5 days old; `fulfilled`, `archived`, `trash` states.
- guest: `new` if this guest hasn't seen the wish yet (tracked by a cookie-based `guestId`); fulfilled wishes look visually dimmed.
- Archive and trash are visible **only** to the owner.
- Profile page at `/u/:slug`, public and readable by anyone with the link.
---
## Repository layout
```
apps/
backend/ Fastify API, Prisma schema, jobs, scripts
frontend/ React SPA
packages/
shared/ zod schemas + DTO types shared between backend and frontend
docker/ Dockerfiles + nginx.conf
docker-compose.yml prod stack (shared postgres + backend + frontend)
docker-compose.dev.yml dev helper (postgres only)
.env.example full env template
```
### Backend layout (`apps/backend/src`)
```
config/env.ts zod-validated process.env + resolveUsers()
auth/users.registry.ts in-memory user registry, single source of truth for credentials
plugins/ fastify plugins (auth, cors, cookie, jwt, rate-limit, static, guest, multipart, prisma)
modules/
auth/ login / logout / me
profile/ GET / PATCH current user's profile
wishes/ CRUD + archive/complete/restore/duplicate + soft-delete
images/ OG-image fetcher + multipart upload + reset
public/ public profile and wishes + guest view tracking
meta/ /api/health + /api/version
jobs/purge-trash.ts node-cron daily purge of wishes older than 30 days
utils/ errors, bcrypt helpers, version helper
```
### Database (Prisma)
See `apps/backend/prisma/schema.prisma`. Tables:
- `User``id`, `username`, `slug`, `displayName`, `bio`, `avatarUrl`. **No password hash.**
- `Wish``userId`, `title`, `price`, `currency`, `url`, `comment`, `imageUrl`, `imageSource`, `status`, timestamps, `sourceWishId` (for duplicates).
- `GuestView``(guestId, wishId)` pairs for "already seen" tracking.
### Password storage
The plain password is **never stored** in this application — not in the database, not in env, not in logs. Storage split:
| Value | Location |
|---|---|
| Original password | Owner's password manager only |
| bcrypt hash | `.env` (`USER1_PASSWORD_HASH`, `USER2_PASSWORD_HASH`) |
| Users registry | Built from env on process start, lives in RAM |
| DB | Public fields only (username, slug, displayName) |
| JWT | `{ id, username }` — no credentials |
Verification flow (see `apps/backend/src/modules/auth/auth.service.ts`):
1. Client `POST /api/auth/login` with `{ username, password }` over HTTPS.
2. Server looks up user in the in-memory registry by `username`.
3. `bcrypt.compare(password, user.passwordHash)` runs unconditionally (dummy compare if the username is unknown) — this prevents username enumeration via response timing.
4. On success: signed JWT set as an httpOnly cookie (`fw_auth`). Rate limit: 5 logins / 10 minutes per IP.
5. Fastify logger redacts `req.body.password`, `req.headers.cookie`, `req.headers.authorization`.
---
## Getting started
### Prerequisites
- Node.js 20+
- pnpm 9+ (`corepack enable && corepack prepare pnpm@9.12.0 --activate`)
- Docker + Docker Compose (for the full stack)
- OpenSSL (for generating secrets)
### 1. Initialize git + install deps
```bash
git init -b main
pnpm install
```
### 2. Prepare the environment file
```bash
cp .env.example .env
```
Generate two strong secrets:
```bash
openssl rand -hex 32 # paste into JWT_SECRET
openssl rand -hex 32 # paste into COOKIE_SECRET
```
Generate a bcrypt hash for each user's password (the password is only present in your shell history — clear it afterwards if you care):
```bash
pnpm hash-password "Alice's strong password"
# => $2b$12$eImiTXuWV... copy this into USER1_PASSWORD_HASH
pnpm hash-password "Bob's strong password"
# => $2b$12$aBcDeFgHi... copy this into USER2_PASSWORD_HASH
```
Review the rest of `.env`:
- `USER1_USERNAME`, `USER1_SLUG`, `USER1_DISPLAY_NAME`
- `USER2_USERNAME`, `USER2_SLUG`, `USER2_DISPLAY_NAME`
- `DB_HOST=postgres_budget`, `DB_PORT=5432`, `DB_NAME=db_family`
- `DB_USER`, `DB_PASSWORD`, `DATABASE_URL` (sensitive values stay only in `.env`)
- `PUBLIC_APP_URL` (used for CORS in production)
### 3. Run the shared Docker stack
```bash
docker compose up --build
```
Opens:
- Frontend: http://localhost:8080
- Backend API: http://localhost:8080/api (proxied by nginx) or http://localhost:3000 if you map `family-wishlist-backend`
Before first start, create a dedicated database and user for this project in the existing Postgres host:
- host: `postgres_budget`
- port: `5432`
- database: `db_family`
- user/password: set only in `.env` and do not commit them
On first start, the backend:
1. Runs `prisma db push` against the configured shared Postgres database (creates tables from `schema.prisma`; idempotent).
2. Seeds/upserts both users from env (public fields only — password hash stays in env).
3. Starts Fastify on port 3000.
4. Registers the daily trash-purge cron (runs at 03:17 UTC, also once on startup).
> For a stricter migration workflow, switch to `prisma migrate dev` locally to produce
> versioned migration files and change the Docker `CMD` to `prisma migrate deploy`.
### 4. Local development (hot reload)
Run Postgres in a container, apps on the host:
```bash
docker compose -f docker-compose.dev.yml up -d
# Override DATABASE_URL to point to localhost:
# DATABASE_URL=postgresql://<DB_USER>:<DB_PASSWORD>@localhost:5432/db_family
pnpm --filter @family-wishlist/backend prisma:push # apply schema (first time and on schema changes)
pnpm --filter @family-wishlist/backend seed # upsert two users from env into DB
pnpm dev
```
This starts both apps in parallel:
- Frontend: http://localhost:5173 (proxying `/api` and `/uploads` to http://localhost:3000)
- Backend: http://localhost:3000
- Dev Postgres: `localhost:5432` for local-only development data
The dev compose file stays isolated from the shared `postgres_budget` instance. Keep production credentials and local credentials in `.env`, and never hardcode them in compose files or source code.
### 5. Useful scripts
```bash
pnpm typecheck # typecheck all workspaces
pnpm format # prettier on the whole repo
pnpm hash-password "<password>" # print bcrypt hash to stdout
pnpm --filter @family-wishlist/backend prisma:studio # GUI over DB
```
---
## API
All responses are JSON. Authenticated endpoints require the `fw_auth` cookie.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | `/api/auth/login` | — | Returns auth user, sets cookie. Rate-limited. |
| POST | `/api/auth/logout` | — | Clears cookie. |
| GET | `/api/auth/me` | owner | Current user. |
| GET | `/api/profile` | owner | Own profile. |
| PATCH | `/api/profile` | owner | Update slug/displayName/bio/avatar. |
| GET | `/api/wishes?status=active\|archived\|completed\|deleted` | owner | List own wishes. |
| POST | `/api/wishes` | owner | Create. |
| GET | `/api/wishes/:id` | owner | Get own wish. |
| PATCH | `/api/wishes/:id` | owner | Update. |
| DELETE | `/api/wishes/:id` | owner | Soft-delete (to trash). |
| POST | `/api/wishes/:id/archive` | owner | |
| POST | `/api/wishes/:id/complete` | owner | |
| POST | `/api/wishes/:id/restore` | owner | From archive or trash back to active. |
| POST | `/api/wishes/:id/duplicate` | owner | Copy fulfilled wish into a new active one. |
| POST | `/api/wishes/:id/image` | owner | Multipart image upload. |
| POST | `/api/wishes/:id/image/refresh-og` | owner | Re-fetch OG image from link. |
| DELETE | `/api/wishes/:id/image` | owner | Reset to default. |
| GET | `/api/public/:slug` | — | Public profile. |
| GET | `/api/public/:slug/wishes` | — | Active + fulfilled wishes. Sets `isNewForGuest`. |
| POST | `/api/public/:slug/views` | — | Body `{ wishIds: [] }`. Marks as seen. |
| GET | `/api/health`, `/api/version` | — | Meta. |
---
## Versioning
Each package has its own version (`apps/backend/package.json` and `apps/frontend/package.json`). The app footer shows both:
```
frontend v0.3.4 · backend v0.3.0
```
Bump them per semver on each change:
- patch — bug fixes and small non-breaking tweaks,
- minor — backward-compatible features,
- major — breaking changes.
---
## Security notes
- Cookies are `httpOnly`, `SameSite=Lax`, `Secure` in production.
- All auth-scoped routes re-check ownership in the service layer (`userId` vs `req.user.id`).
- `/api/auth/login` is rate-limited; username enumeration is mitigated by a constant-time dummy compare.
- Image fetcher has a 10s timeout, a 5 MB cap, and accepts only `image/jpeg|png|webp|gif`.
- Multipart upload cap: 8 MB, one file per request.
- Prisma cascades delete `GuestView` rows when their wish is purged.
- In production, terminate TLS in front of the nginx container (Caddy / Traefik / cloud load balancer).

View File

@@ -0,0 +1,6 @@
node_modules
dist
uploads
.env
.env.*
*.log

51
apps/backend/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "@family-wishlist/backend",
"version": "0.3.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "echo 'skip'",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy",
"prisma:studio": "prisma studio",
"seed": "tsx prisma/seed.ts",
"hash-password": "tsx scripts/hash-password.ts"
},
"dependencies": {
"@family-wishlist/shared": "workspace:*",
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"@fastify/helmet": "^11.1.1",
"@fastify/jwt": "^8.0.1",
"@fastify/multipart": "^8.3.0",
"@fastify/rate-limit": "^9.1.0",
"@fastify/sensible": "^5.6.0",
"@fastify/static": "^7.0.4",
"@prisma/client": "^5.19.1",
"bcryptjs": "^2.4.3",
"fastify": "^4.28.1",
"fastify-plugin": "^4.5.1",
"fastify-type-provider-zod": "^2.0.0",
"nanoid": "^5.0.7",
"node-cron": "^3.0.3",
"open-graph-scraper": "^6.8.3",
"pino-pretty": "^11.2.2",
"undici": "^6.19.8",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.16.5",
"@types/node-cron": "^3.0.11",
"prisma": "^5.19.1",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
}
}

View File

@@ -0,0 +1,81 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// --------------------------------------------------------------------
// Users
//
// There are exactly two users in this application. Their credentials
// (username + bcrypt hash) live in env — see apps/backend/src/config/env.ts.
// The DB stores only "public" fields to scope wishes and serve public
// profiles. The password hash is intentionally NOT stored here; this keeps
// the single source of truth for credentials in env and limits the
// blast-radius of a DB dump.
// --------------------------------------------------------------------
model User {
id String @id
username String @unique
slug String @unique
displayName String
bio String?
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
wishes Wish[]
}
enum WishStatus {
ACTIVE
ARCHIVED
COMPLETED
DELETED
}
enum ImageSource {
DEFAULT
OG
UPLOADED
}
model Wish {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
price Decimal? @db.Decimal(12, 2)
currency String @default("RUB")
url String?
comment String?
imageUrl String?
imageSource ImageSource @default(DEFAULT)
status WishStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
completedAt DateTime?
deletedAt DateTime?
sourceWishId String?
views GuestView[]
@@index([userId, status])
@@index([deletedAt])
@@index([createdAt])
}
model GuestView {
id String @id @default(cuid())
guestId String
wishId String
wish Wish @relation(fields: [wishId], references: [id], onDelete: Cascade)
seenAt DateTime @default(now())
@@unique([guestId, wishId])
@@index([guestId])
}

View File

@@ -0,0 +1,37 @@
import { PrismaClient } from '@prisma/client';
import { resolveUsers } from '../src/config/env.js';
const prisma = new PrismaClient();
async function main(): Promise<void> {
const users = resolveUsers();
for (const u of users) {
await prisma.user.upsert({
where: { username: u.username },
update: {
id: u.id,
slug: u.slug,
displayName: u.displayName,
},
create: {
id: u.id,
username: u.username,
slug: u.slug,
displayName: u.displayName,
},
});
// eslint-disable-next-line no-console
console.log(`seeded user: ${u.username} (slug=${u.slug}, id=${u.id})`);
}
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (err) => {
// eslint-disable-next-line no-console
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View File

@@ -0,0 +1,27 @@
// Local CLI helper — produces a bcrypt hash for an env USER*_PASSWORD_HASH value.
// Usage:
// pnpm hash-password "mySuperSecret"
//
// The plain password is only present in argv/RAM during this invocation.
// It is NOT logged anywhere; only the hash is printed to stdout. Copy it into .env.
import { hashPassword } from '../src/utils/password.js';
async function main(): Promise<void> {
const raw = process.argv.slice(2).join(' ').trim();
if (!raw) {
// eslint-disable-next-line no-console
console.error('Usage: pnpm hash-password "<password>"');
process.exit(1);
}
if (raw.length < 8) {
// eslint-disable-next-line no-console
console.error('Password must be at least 8 characters.');
process.exit(1);
}
const hash = await hashPassword(raw);
// Print ONLY the hash, nothing else, so it is trivial to redirect/copy.
process.stdout.write(hash + '\n');
}
void main();

86
apps/backend/src/app.ts Normal file
View File

@@ -0,0 +1,86 @@
import Fastify, { type FastifyInstance } from 'fastify';
import helmet from '@fastify/helmet';
import sensible from '@fastify/sensible';
import { ZodError } from 'zod';
import { env } from './config/env.js';
import { HttpError } from './utils/errors.js';
import prismaPlugin from './plugins/prisma.js';
import corsPlugin from './plugins/cors.js';
import rateLimitPlugin from './plugins/rate-limit.js';
import authPlugin from './plugins/auth.js';
import guestPlugin from './plugins/guest.js';
import staticPlugin from './plugins/static.js';
import multipartPlugin from './plugins/multipart.js';
import authRoutes from './modules/auth/auth.routes.js';
import profileRoutes from './modules/profile/profile.routes.js';
import wishesRoutes from './modules/wishes/wishes.routes.js';
import imagesRoutes from './modules/images/images.routes.js';
import publicRoutes from './modules/public/public.routes.js';
import metaRoutes from './modules/meta/meta.routes.js';
import { registerPurgeTrashJob } from './jobs/purge-trash.js';
export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
logger: {
level: env.LOG_LEVEL,
transport:
env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss', singleLine: true } }
: undefined,
redact: {
paths: ['req.body.password', 'req.headers.cookie', 'req.headers.authorization'],
remove: true,
},
},
trustProxy: true,
bodyLimit: 1 * 1024 * 1024,
});
app.setErrorHandler((err, request, reply) => {
if (err instanceof HttpError) {
return reply.code(err.statusCode).send({
error: err.code,
message: err.message,
details: err.details,
});
}
if (err instanceof ZodError) {
return reply.code(400).send({
error: 'VALIDATION',
message: 'Invalid input',
details: err.flatten(),
});
}
if ((err as { statusCode?: number }).statusCode === 429) {
return reply.code(429).send({ error: 'RATE_LIMITED', message: err.message });
}
request.log.error({ err }, 'Unhandled error');
return reply.code(500).send({ error: 'INTERNAL', message: 'Internal server error' });
});
await app.register(helmet, {
contentSecurityPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' },
});
await app.register(sensible);
await app.register(corsPlugin);
await app.register(rateLimitPlugin);
await app.register(authPlugin);
await app.register(guestPlugin);
await app.register(staticPlugin);
await app.register(multipartPlugin);
await app.register(prismaPlugin);
await app.register(metaRoutes, { prefix: '/api' });
await app.register(authRoutes, { prefix: '/api/auth' });
await app.register(profileRoutes, { prefix: '/api/profile' });
await app.register(wishesRoutes, { prefix: '/api/wishes' });
await app.register(imagesRoutes, { prefix: '/api/wishes' });
await app.register(publicRoutes, { prefix: '/api/public' });
registerPurgeTrashJob(app);
return app;
}

View File

@@ -0,0 +1,30 @@
import { resolveUsers } from '../config/env.js';
import type { RegistryUser } from './users.registry.types.js';
// Built once on process start from env. Source of truth for credentials.
// DB contains only "public" copies (id, username, slug, displayName) — never the hash.
const users: RegistryUser[] = resolveUsers();
const byUsername = new Map(users.map((u) => [u.username, u] as const));
const byId = new Map(users.map((u) => [u.id, u] as const));
export const usersRegistry = {
all(): readonly RegistryUser[] {
return users;
},
findByUsername(username: string): RegistryUser | undefined {
return byUsername.get(username);
},
findById(id: string): RegistryUser | undefined {
return byId.get(id);
},
};
// Pre-computed bcrypt hash of a random string, used for timing-safe compare
// when the requested username does not exist. Generated lazily on first need.
let dummyHashCache: string | null = null;
export async function getDummyHash(): Promise<string> {
if (dummyHashCache) return dummyHashCache;
const { default: bcrypt } = await import('bcryptjs');
dummyHashCache = await bcrypt.hash('__not_a_real_password__', 10);
return dummyHashCache;
}

View File

@@ -0,0 +1,7 @@
export interface RegistryUser {
id: string;
username: string;
passwordHash: string;
slug: string;
displayName: string;
}

View File

@@ -0,0 +1,89 @@
import { z } from 'zod';
import crypto from 'node:crypto';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
BACKEND_PORT: z.coerce.number().int().positive().default(3000),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
DATABASE_URL: z.string().url(),
PUBLIC_APP_URL: z.string().url().default('http://localhost:8080'),
UPLOADS_DIR: z.string().default('./uploads'),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
COOKIE_SECRET: z.string().min(32, 'COOKIE_SECRET must be at least 32 chars'),
USER1_USERNAME: z.string().min(3).max(64),
USER1_PASSWORD_HASH: z.string().min(20, 'USER1_PASSWORD_HASH must be a bcrypt hash'),
USER1_SLUG: z.string().min(3).max(32),
USER1_DISPLAY_NAME: z.string().min(1).max(64),
USER2_USERNAME: z.string().min(3).max(64),
USER2_PASSWORD_HASH: z.string().min(20, 'USER2_PASSWORD_HASH must be a bcrypt hash'),
USER2_SLUG: z.string().min(3).max(32),
USER2_DISPLAY_NAME: z.string().min(1).max(64),
});
export type Env = z.infer<typeof envSchema>;
function parseEnv(): Env {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
// eslint-disable-next-line no-console
console.error('\nInvalid environment configuration:\n');
for (const issue of parsed.error.issues) {
// eslint-disable-next-line no-console
console.error(` - ${issue.path.join('.')}: ${issue.message}`);
}
process.exit(1);
}
return parsed.data;
}
export const env = parseEnv();
export interface EnvUserConfig {
id: string;
username: string;
passwordHash: string;
slug: string;
displayName: string;
}
function stableUserId(username: string): string {
// 24-char stable id derived from username so DB seed can upsert deterministically
// without depending on any external secret.
return 'u_' + crypto.createHash('sha256').update(`user:${username}`).digest('hex').slice(0, 22);
}
export function resolveUsers(): EnvUserConfig[] {
const usernames = new Set<string>();
const slugs = new Set<string>();
const users: EnvUserConfig[] = [
{
id: stableUserId(env.USER1_USERNAME),
username: env.USER1_USERNAME,
passwordHash: env.USER1_PASSWORD_HASH,
slug: env.USER1_SLUG,
displayName: env.USER1_DISPLAY_NAME,
},
{
id: stableUserId(env.USER2_USERNAME),
username: env.USER2_USERNAME,
passwordHash: env.USER2_PASSWORD_HASH,
slug: env.USER2_SLUG,
displayName: env.USER2_DISPLAY_NAME,
},
];
for (const u of users) {
if (usernames.has(u.username)) {
throw new Error(`Duplicate USER*_USERNAME: ${u.username}`);
}
if (slugs.has(u.slug)) {
throw new Error(`Duplicate USER*_SLUG: ${u.slug}`);
}
usernames.add(u.username);
slugs.add(u.slug);
}
return users;
}

32
apps/backend/src/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import { buildApp } from './app.js';
import { env } from './config/env.js';
async function main(): Promise<void> {
const app = await buildApp();
try {
await app.listen({ port: env.BACKEND_PORT, host: '0.0.0.0' });
} catch (err) {
app.log.error({ err }, 'failed to start');
process.exit(1);
}
const shutdown = async (signal: NodeJS.Signals) => {
app.log.info({ signal }, 'shutting down');
try {
await app.close();
process.exit(0);
} catch (err) {
app.log.error({ err }, 'shutdown error');
process.exit(1);
}
};
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});
process.on('SIGINT', () => {
void shutdown('SIGINT');
});
}
void main();

View File

@@ -0,0 +1,48 @@
import cron from 'node-cron';
import type { FastifyInstance } from 'fastify';
import { TRASH_RETENTION_DAYS } from '@family-wishlist/shared';
import { deleteLocalImageIfAny } from '../modules/images/storage.service.js';
async function purge(app: FastifyInstance): Promise<number> {
const cutoff = new Date(Date.now() - TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000);
const victims = await app.prisma.wish.findMany({
where: { status: 'DELETED', deletedAt: { lt: cutoff } },
select: { id: true, imageUrl: true },
});
if (victims.length === 0) return 0;
await Promise.all(victims.map((v) => deleteLocalImageIfAny(v.imageUrl)));
const res = await app.prisma.wish.deleteMany({
where: { id: { in: victims.map((v) => v.id) } },
});
return res.count;
}
export function registerPurgeTrashJob(app: FastifyInstance): void {
// Run daily at 03:17 (chosen to avoid common cron rush).
const task = cron.schedule(
'17 3 * * *',
async () => {
try {
const count = await purge(app);
if (count > 0) app.log.info({ count }, 'trash: purged expired wishes');
} catch (err) {
app.log.error({ err }, 'trash: purge failed');
}
},
{ scheduled: true, timezone: 'UTC' },
);
app.addHook('onClose', async () => {
task.stop();
});
// Also run once on startup to catch up if backend was offline for a while.
setTimeout(() => {
purge(app)
.then((count) => {
if (count > 0) app.log.info({ count }, 'trash: startup purge');
})
.catch((err) => app.log.error({ err }, 'trash: startup purge failed'));
}, 5_000);
}

View File

@@ -0,0 +1,43 @@
import type { FastifyInstance } from 'fastify';
import { loginSchema } from '@family-wishlist/shared';
import { verifyCredentials } from './auth.service.js';
import { usersRegistry } from '../../auth/users.registry.js';
import { UnauthorizedError } from '../../utils/errors.js';
export default async function authRoutes(app: FastifyInstance) {
app.post(
'/login',
{
config: {
rateLimit: { max: 5, timeWindow: '10 minutes' },
},
},
async (request, reply) => {
const body = loginSchema.parse(request.body);
const user = await verifyCredentials(body.username, body.password);
const token = await reply.jwtSign({ id: user.id, username: user.username });
app.setAuthCookie(reply, token);
return {
id: user.id,
username: user.username,
slug: user.slug,
displayName: user.displayName,
};
},
);
app.post('/logout', async (_request, reply) => {
app.clearAuthCookie(reply);
return { ok: true };
});
app.get(
'/me',
{ preHandler: [app.authenticate] },
async (request) => {
const u = usersRegistry.findById(request.user.id);
if (!u) throw new UnauthorizedError();
return { id: u.id, username: u.username, slug: u.slug, displayName: u.displayName };
},
);
}

View File

@@ -0,0 +1,25 @@
import { verifyPassword } from '../../utils/password.js';
import { getDummyHash, usersRegistry } from '../../auth/users.registry.js';
import { InvalidCredentialsError } from '../../utils/errors.js';
export interface AuthenticatedUser {
id: string;
username: string;
slug: string;
displayName: string;
}
export async function verifyCredentials(
username: string,
password: string,
): Promise<AuthenticatedUser> {
const user = usersRegistry.findByUsername(username);
// Always run bcrypt.compare to keep response time stable regardless of whether
// the username exists. Otherwise an attacker could enumerate usernames by timing.
const hash = user?.passwordHash ?? (await getDummyHash());
const ok = await verifyPassword(password, hash);
if (!user || !ok) {
throw new InvalidCredentialsError();
}
return { id: user.id, username: user.username, slug: user.slug, displayName: user.displayName };
}

View File

@@ -0,0 +1,50 @@
import type { FastifyInstance } from 'fastify';
import { MAX_UPLOAD_BYTES } from '../../plugins/multipart.js';
import { WishesService } from '../wishes/wishes.service.js';
import { ValidationError } from '../../utils/errors.js';
import { deleteLocalImageIfAny, saveUploadedImage } from './storage.service.js';
import { fetchOgImageForWish } from './og.service.js';
export default async function imagesRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
const wishes = new WishesService(app.prisma);
app.post('/:id/image', async (request) => {
const { id } = request.params as { id: string };
const current = await wishes.getOwned(request.user.id, id);
const data = await request.file();
if (!data) throw new ValidationError('No file uploaded');
const buffer = await data.toBuffer();
if (buffer.byteLength > MAX_UPLOAD_BYTES) {
throw new ValidationError('File too large');
}
const { imageUrl } = await saveUploadedImage(id, data.mimetype, buffer);
await deleteLocalImageIfAny(current.imageUrl);
return app.prisma.wish.update({
where: { id },
data: { imageUrl, imageSource: 'UPLOADED' },
});
});
app.post('/:id/image/refresh-og', async (request) => {
const { id } = request.params as { id: string };
const wish = await wishes.getOwned(request.user.id, id);
if (!wish.url) throw new ValidationError('Wish has no url');
await fetchOgImageForWish(app, id, wish.url);
return app.prisma.wish.findUniqueOrThrow({ where: { id } });
});
app.delete('/:id/image', async (request) => {
const { id } = request.params as { id: string };
const wish = await wishes.getOwned(request.user.id, id);
await deleteLocalImageIfAny(wish.imageUrl);
return app.prisma.wish.update({
where: { id },
data: { imageUrl: null, imageSource: 'DEFAULT' },
});
});
}

View File

@@ -0,0 +1,99 @@
import type { FastifyInstance } from 'fastify';
import { request as undiciRequest } from 'undici';
import ogs from 'open-graph-scraper';
import { writeFile } from 'node:fs/promises';
import { resolve, extname } from 'node:path';
import { nanoid } from 'nanoid';
import { env } from '../../config/env.js';
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
const FETCH_TIMEOUT_MS = 10_000;
interface DownloadResult {
buffer: Buffer;
ext: string;
contentType: string;
}
function getOgImageUrl(ogImage: unknown): string | undefined {
const entry = Array.isArray(ogImage) ? ogImage[0] : ogImage;
if (!entry || typeof entry !== 'object') return undefined;
const { url } = entry as { url?: unknown };
return typeof url === 'string' ? url : undefined;
}
async function downloadImage(url: string): Promise<DownloadResult | null> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await undiciRequest(url, {
method: 'GET',
signal: controller.signal,
headers: { 'user-agent': 'FamilyWishlistBot/1.0 (+image-fetch)' },
});
if (res.statusCode >= 400) return null;
const contentType = ((res.headers['content-type']?.toString() ?? '').split(';')[0] ?? '').trim();
if (!ALLOWED_MIME.has(contentType)) return null;
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of res.body) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buf.length;
if (total > MAX_IMAGE_BYTES) return null;
chunks.push(buf);
}
const buffer = Buffer.concat(chunks);
const extFromCt = contentType.split('/')[1] ?? 'jpg';
const extFromUrl = extname(new URL(url).pathname).replace('.', '').toLowerCase();
const ext = ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(extFromUrl)
? extFromUrl
: extFromCt;
return { buffer, ext, contentType };
} finally {
clearTimeout(timer);
}
} catch {
return null;
}
}
export async function fetchOgImageForWish(
app: FastifyInstance,
wishId: string,
pageUrl: string,
): Promise<void> {
try {
const parsed = await ogs({ url: pageUrl, timeout: FETCH_TIMEOUT_MS });
if (parsed.error || !parsed.result) return;
const imageUrl = getOgImageUrl(parsed.result.ogImage);
if (!imageUrl) return;
const absolute = new URL(imageUrl, pageUrl).toString();
const dl = await downloadImage(absolute);
if (!dl) return;
const filename = `${wishId}-${nanoid(8)}.${dl.ext}`;
const absPath = resolve(env.UPLOADS_DIR, 'og', filename);
await writeFile(absPath, dl.buffer);
const current = await app.prisma.wish.findUnique({ where: { id: wishId } });
if (!current) return;
if (current.imageSource === 'UPLOADED') return; // do not overwrite user upload
await app.prisma.wish.update({
where: { id: wishId },
data: { imageUrl: `/uploads/og/${filename}`, imageSource: 'OG' },
});
} catch (err) {
app.log.warn({ err, wishId, pageUrl }, 'OG image fetch failed');
}
}
export function enqueueOgFetch(app: FastifyInstance, wishId: string, pageUrl: string): void {
// Fire-and-forget. Errors are swallowed inside fetchOgImageForWish.
setImmediate(() => {
void fetchOgImageForWish(app, wishId, pageUrl);
});
}

View File

@@ -0,0 +1,52 @@
import { writeFile, unlink } from 'node:fs/promises';
import { resolve } from 'node:path';
import { nanoid } from 'nanoid';
import { env } from '../../config/env.js';
import { ValidationError } from '../../utils/errors.js';
const MIME_TO_EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
};
export async function saveUploadedImage(
wishId: string,
mime: string,
buffer: Buffer,
): Promise<{ imageUrl: string }> {
const ext = MIME_TO_EXT[mime];
if (!ext) throw new ValidationError('Unsupported image type');
const filename = `${wishId}-${nanoid(8)}.${ext}`;
const relative = `/uploads/upload/${filename}`;
const absPath = resolve(env.UPLOADS_DIR, 'upload', filename);
await writeFile(absPath, buffer);
return { imageUrl: relative };
}
export async function saveUploadedAvatar(
userId: string,
mime: string,
buffer: Buffer,
): Promise<{ imageUrl: string }> {
const ext = MIME_TO_EXT[mime];
if (!ext) throw new ValidationError('Unsupported image type');
const filename = `${userId}-${nanoid(8)}.${ext}`;
const relative = `/uploads/avatar/${filename}`;
const absPath = resolve(env.UPLOADS_DIR, 'avatar', filename);
await writeFile(absPath, buffer);
return { imageUrl: relative };
}
export async function deleteLocalImageIfAny(imageUrl: string | null): Promise<void> {
if (!imageUrl) return;
if (!imageUrl.startsWith('/uploads/')) return;
const rel = imageUrl.replace(/^\/uploads\//, '');
const absPath = resolve(env.UPLOADS_DIR, rel);
try {
await unlink(absPath);
} catch {
// already gone — ignore
}
}

View File

@@ -0,0 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { getBackendVersion } from '../../utils/version.js';
export default async function metaRoutes(app: FastifyInstance) {
app.get('/version', async () => ({ backend: getBackendVersion() }));
app.get('/health', async () => ({ status: 'ok', ts: new Date().toISOString() }));
}

View File

@@ -0,0 +1,65 @@
import type { FastifyInstance } from 'fastify';
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;
export default async function profileRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
app.get('/', async (request) => {
const profile = await app.prisma.user.findUnique({ where: { id: request.user.id } });
if (!profile) throw new NotFoundError('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) => {
const body = updateProfileSchema.parse(request.body);
try {
const updated = await app.prisma.user.update({
where: { id: request.user.id },
data: body,
});
return updated;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw new ConflictError('Slug is already taken');
}
throw err;
}
});
app.post('/avatar', async (request) => {
const current = await app.prisma.user.findUnique({ where: { id: request.user.id } });
if (!current) throw new NotFoundError('Profile');
const data = await request.file();
if (!data) throw new ValidationError('No file uploaded');
const buffer = await data.toBuffer();
if (buffer.byteLength > MAX_AVATAR_BYTES) {
throw new ValidationError('Avatar must be 2 MB or less');
}
const { imageUrl } = await saveUploadedAvatar(request.user.id, data.mimetype, buffer);
await deleteLocalImageIfAny(current.avatarUrl);
return app.prisma.user.update({
where: { id: request.user.id },
data: { avatarUrl: imageUrl },
});
});
}

View File

@@ -0,0 +1,58 @@
import type { FastifyInstance } from 'fastify';
import { markSeenSchema } from '@family-wishlist/shared';
import { NotFoundError } from '../../utils/errors.js';
export default async function publicRoutes(app: FastifyInstance) {
app.get('/:slug', async (request) => {
const { slug } = request.params as { slug: string };
const user = await app.prisma.user.findUnique({
where: { slug },
select: { slug: true, displayName: true, bio: true, avatarUrl: true },
});
if (!user) throw new NotFoundError('Profile');
return user;
});
app.get('/:slug/wishes', async (request) => {
const { slug } = request.params as { slug: string };
const user = await app.prisma.user.findUnique({ where: { slug }, select: { id: true } });
if (!user) throw new NotFoundError('Profile');
const wishes = await app.prisma.wish.findMany({
where: { userId: user.id, status: { in: ['ACTIVE', 'COMPLETED'] } },
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
});
const wishIds = wishes.map((w) => w.id);
const seen = wishIds.length
? await app.prisma.guestView.findMany({
where: { guestId: request.guestId, wishId: { in: wishIds } },
select: { wishId: true },
})
: [];
const seenSet = new Set(seen.map((s) => s.wishId));
return wishes.map((w) => ({
...w,
isNewForGuest: w.status === 'ACTIVE' && !seenSet.has(w.id),
}));
});
app.post('/:slug/views', async (request) => {
const { slug } = request.params as { slug: string };
const body = markSeenSchema.parse(request.body);
const user = await app.prisma.user.findUnique({ where: { slug }, select: { id: true } });
if (!user) throw new NotFoundError('Profile');
// Filter wishIds to those that actually belong to this user (avoid cross-user pollution).
const owned = await app.prisma.wish.findMany({
where: { userId: user.id, id: { in: body.wishIds } },
select: { id: true },
});
if (owned.length === 0) return { marked: 0 };
const data = owned.map((w) => ({ guestId: request.guestId, wishId: w.id }));
const res = await app.prisma.guestView.createMany({ data, skipDuplicates: true });
return { marked: res.count };
});
}

View File

@@ -0,0 +1,75 @@
import type { FastifyInstance } from 'fastify';
import {
createWishSchema,
updateWishSchema,
wishStatusQuery,
NEW_BADGE_DAYS,
} from '@family-wishlist/shared';
import { WishesService } from './wishes.service.js';
import { enqueueOgFetch } from '../images/og.service.js';
export default async function wishesRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
const service = new WishesService(app.prisma);
app.get('/', async (request) => {
const qs = wishStatusQuery.parse((request.query as { status?: string })?.status ?? 'active');
const wishes = await service.list(request.user.id, qs);
return wishes.map((w) => ({
...w,
isNewForOwner:
w.status === 'ACTIVE' &&
Date.now() - w.createdAt.getTime() < NEW_BADGE_DAYS * 24 * 60 * 60 * 1000,
}));
});
app.get('/:id', async (request) => {
const { id } = request.params as { id: string };
return service.getOwned(request.user.id, id);
});
app.post('/', async (request, reply) => {
const input = createWishSchema.parse(request.body);
const wish = await service.create(request.user.id, input);
if (wish.url) enqueueOgFetch(app, wish.id, wish.url);
reply.code(201);
return wish;
});
app.patch('/:id', async (request) => {
const { id } = request.params as { id: string };
const input = updateWishSchema.parse(request.body);
const updated = await service.update(request.user.id, id, input);
if (input.url !== undefined && updated.url) {
enqueueOgFetch(app, updated.id, updated.url);
}
return updated;
});
app.delete('/:id', async (request) => {
const { id } = request.params as { id: string };
return service.softDelete(request.user.id, id);
});
app.post('/:id/archive', async (request) => {
const { id } = request.params as { id: string };
return service.archive(request.user.id, id);
});
app.post('/:id/complete', async (request) => {
const { id } = request.params as { id: string };
return service.complete(request.user.id, id);
});
app.post('/:id/restore', async (request) => {
const { id } = request.params as { id: string };
return service.restore(request.user.id, id);
});
app.post('/:id/duplicate', async (request, reply) => {
const { id } = request.params as { id: string };
const wish = await service.duplicate(request.user.id, id);
reply.code(201);
return wish;
});
}

View File

@@ -0,0 +1,117 @@
import type { PrismaClient, Wish, WishStatus } from '@prisma/client';
import {
ConflictError,
ForbiddenError,
NotFoundError,
} from '../../utils/errors.js';
import type { CreateWishInput, UpdateWishInput } from '@family-wishlist/shared';
type Status = 'active' | 'archived' | 'completed' | 'deleted';
const statusMap: Record<Status, WishStatus> = {
active: 'ACTIVE',
archived: 'ARCHIVED',
completed: 'COMPLETED',
deleted: 'DELETED',
};
export class WishesService {
constructor(private readonly prisma: PrismaClient) {}
list(userId: string, status: Status): Promise<Wish[]> {
return this.prisma.wish.findMany({
where: { userId, status: statusMap[status] },
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
});
}
async getOwned(userId: string, id: string): Promise<Wish> {
const wish = await this.prisma.wish.findUnique({ where: { id } });
if (!wish) throw new NotFoundError('Wish');
if (wish.userId !== userId) throw new ForbiddenError();
return wish;
}
create(userId: string, input: CreateWishInput): Promise<Wish> {
return this.prisma.wish.create({
data: {
userId,
title: input.title,
price: input.price ?? null,
currency: input.currency ?? 'RUB',
url: input.url ?? null,
comment: input.comment ?? null,
},
});
}
async update(userId: string, id: string, input: UpdateWishInput): Promise<Wish> {
await this.getOwned(userId, id);
const data: Record<string, unknown> = {};
if (input.title !== undefined) data.title = input.title;
if (input.price !== undefined) data.price = input.price ?? null;
if (input.currency !== undefined) data.currency = input.currency ?? 'RUB';
if (input.url !== undefined) data.url = input.url ?? null;
if (input.comment !== undefined) data.comment = input.comment ?? null;
return this.prisma.wish.update({ where: { id }, data });
}
async archive(userId: string, id: string): Promise<Wish> {
const wish = await this.getOwned(userId, id);
if (wish.status === 'ARCHIVED') return wish;
if (wish.status === 'DELETED') {
throw new ConflictError('Cannot archive a deleted wish; restore it first');
}
return this.prisma.wish.update({
where: { id },
data: { status: 'ARCHIVED', archivedAt: new Date(), completedAt: null, deletedAt: null },
});
}
async complete(userId: string, id: string): Promise<Wish> {
const wish = await this.getOwned(userId, id);
if (wish.status === 'COMPLETED') return wish;
if (wish.status === 'DELETED') {
throw new ConflictError('Cannot complete a deleted wish; restore it first');
}
return this.prisma.wish.update({
where: { id },
data: { status: 'COMPLETED', completedAt: new Date(), archivedAt: null, deletedAt: null },
});
}
async softDelete(userId: string, id: string): Promise<Wish> {
const wish = await this.getOwned(userId, id);
if (wish.status === 'DELETED') return wish;
return this.prisma.wish.update({
where: { id },
data: { status: 'DELETED', deletedAt: new Date() },
});
}
async restore(userId: string, id: string): Promise<Wish> {
const wish = await this.getOwned(userId, id);
if (wish.status === 'ACTIVE') return wish;
return this.prisma.wish.update({
where: { id },
data: { status: 'ACTIVE', archivedAt: null, completedAt: null, deletedAt: null },
});
}
async duplicate(userId: string, id: string): Promise<Wish> {
const source = await this.getOwned(userId, id);
return this.prisma.wish.create({
data: {
userId,
title: source.title,
price: source.price,
currency: source.currency,
url: source.url,
comment: source.comment,
imageUrl: source.imageSource === 'UPLOADED' ? null : source.imageUrl,
imageSource: source.imageSource === 'UPLOADED' ? 'DEFAULT' : source.imageSource,
sourceWishId: source.id,
},
});
}
}

View File

@@ -0,0 +1,52 @@
import fp from 'fastify-plugin';
import fastifyJwt from '@fastify/jwt';
import fastifyCookie from '@fastify/cookie';
import type { FastifyReply } from 'fastify';
import { env } from '../config/env.js';
import { UnauthorizedError } from '../utils/errors.js';
export const AUTH_COOKIE = 'fw_auth';
const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
export default fp(async (app) => {
await app.register(fastifyCookie, {
secret: env.COOKIE_SECRET,
parseOptions: {},
});
await app.register(fastifyJwt, {
secret: env.JWT_SECRET,
cookie: { cookieName: AUTH_COOKIE, signed: false },
sign: { expiresIn: `${AUTH_COOKIE_MAX_AGE}s` },
});
app.decorate('authenticate', async (request) => {
try {
await request.jwtVerify({ onlyCookie: true });
} catch {
throw new UnauthorizedError();
}
});
// helpers for routes
app.decorate('setAuthCookie', ((reply: FastifyReply, token: string) => {
reply.setCookie(AUTH_COOKIE, token, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: AUTH_COOKIE_MAX_AGE,
});
}) as never);
app.decorate('clearAuthCookie', ((reply: FastifyReply) => {
reply.clearCookie(AUTH_COOKIE, { path: '/' });
}) as never);
});
declare module 'fastify' {
interface FastifyInstance {
setAuthCookie: (reply: import('fastify').FastifyReply, token: string) => void;
clearAuthCookie: (reply: import('fastify').FastifyReply) => void;
}
}

View File

@@ -0,0 +1,10 @@
import fp from 'fastify-plugin';
import fastifyCors from '@fastify/cors';
import { env } from '../config/env.js';
export default fp(async (app) => {
await app.register(fastifyCors, {
origin: env.NODE_ENV === 'production' ? env.PUBLIC_APP_URL : true,
credentials: true,
});
});

View File

@@ -0,0 +1,25 @@
import fp from 'fastify-plugin';
import { nanoid } from 'nanoid';
import { env } from '../config/env.js';
export const GUEST_COOKIE = 'fw_gid';
const GUEST_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2; // 2 years
export default fp(async (app) => {
app.addHook('onRequest', async (request, reply) => {
const existing = request.cookies[GUEST_COOKIE];
if (existing && existing.length >= 16 && existing.length <= 64) {
request.guestId = existing;
return;
}
const id = nanoid(24);
request.guestId = id;
reply.setCookie(GUEST_COOKIE, id, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: GUEST_COOKIE_MAX_AGE,
});
});
});

View File

@@ -0,0 +1,13 @@
import fp from 'fastify-plugin';
import fastifyMultipart from '@fastify/multipart';
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; // 8 MB
export default fp(async (app) => {
await app.register(fastifyMultipart, {
limits: {
fileSize: MAX_UPLOAD_BYTES,
files: 1,
},
});
});

View File

@@ -0,0 +1,18 @@
import { PrismaClient } from '@prisma/client';
import fp from 'fastify-plugin';
export default fp(async (app) => {
const prisma = new PrismaClient({
log: [{ emit: 'event', level: 'error' }, { emit: 'event', level: 'warn' }],
});
prisma.$on('error', (e) => app.log.error({ prisma: e }, 'prisma error'));
prisma.$on('warn', (e) => app.log.warn({ prisma: e }, 'prisma warn'));
await prisma.$connect();
app.decorate('prisma', prisma);
app.addHook('onClose', async () => {
await prisma.$disconnect();
});
});

View File

@@ -0,0 +1,8 @@
import fp from 'fastify-plugin';
import fastifyRateLimit from '@fastify/rate-limit';
export default fp(async (app) => {
await app.register(fastifyRateLimit, {
global: false,
});
});

View File

@@ -0,0 +1,20 @@
import fp from 'fastify-plugin';
import fastifyStatic from '@fastify/static';
import { mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { env } from '../config/env.js';
export default fp(async (app) => {
const uploadsRoot = resolve(env.UPLOADS_DIR);
mkdirSync(resolve(uploadsRoot, 'og'), { recursive: true });
mkdirSync(resolve(uploadsRoot, 'upload'), { recursive: true });
mkdirSync(resolve(uploadsRoot, 'avatar'), { recursive: true });
await app.register(fastifyStatic, {
root: uploadsRoot,
prefix: '/uploads/',
decorateReply: false,
index: false,
list: false,
});
});

22
apps/backend/src/types/fastify.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
import type { PrismaClient } from '@prisma/client';
import type { RegistryUser } from '../auth/users.registry.types.js';
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient;
authenticate: (request: import('fastify').FastifyRequest) => Promise<void>;
}
interface FastifyRequest {
user: { id: string; username: string };
authUser?: RegistryUser;
guestId: string;
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: { id: string; username: string };
user: { id: string; username: string };
}
}

View File

@@ -0,0 +1,49 @@
export class HttpError extends Error {
readonly statusCode: number;
readonly code: string;
readonly details?: unknown;
constructor(statusCode: number, code: string, message: string, details?: unknown) {
super(message);
this.name = 'HttpError';
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
export class InvalidCredentialsError extends HttpError {
constructor() {
super(401, 'INVALID_CREDENTIALS', 'Invalid username or password');
}
}
export class UnauthorizedError extends HttpError {
constructor(message = 'Not authenticated') {
super(401, 'UNAUTHORIZED', message);
}
}
export class NotFoundError extends HttpError {
constructor(what = 'Resource') {
super(404, 'NOT_FOUND', `${what} not found`);
}
}
export class ConflictError extends HttpError {
constructor(message: string) {
super(409, 'CONFLICT', message);
}
}
export class ValidationError extends HttpError {
constructor(message: string, details?: unknown) {
super(400, 'VALIDATION', message, details);
}
}
export class ForbiddenError extends HttpError {
constructor(message = 'Forbidden') {
super(403, 'FORBIDDEN', message);
}
}

View File

@@ -0,0 +1,11 @@
import bcrypt from 'bcryptjs';
export const BCRYPT_ROUNDS = 12;
export async function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, BCRYPT_ROUNDS);
}
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}

View File

@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
let cached: string | null = null;
export function getBackendVersion(): string {
if (cached) return cached;
// Walk up until we find apps/backend/package.json.
const candidates = [
resolve(__dirname, '../../package.json'),
resolve(__dirname, '../../../package.json'),
resolve(process.cwd(), 'package.json'),
];
for (const p of candidates) {
try {
const raw = readFileSync(p, 'utf-8');
const pkg = JSON.parse(raw) as { name?: string; version?: string };
if (pkg.name === '@family-wishlist/backend' && pkg.version) {
cached = pkg.version;
return cached;
}
} catch {
// try next
}
}
cached = '0.0.0';
return cached;
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"rootDir": "./",
"outDir": "./dist"
},
"include": ["src/**/*", "prisma/seed.ts"],
"exclude": ["**/*.test.ts", "dist"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"rootDir": "./",
"outDir": "./dist",
"noEmit": true,
"types": ["node"],
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*", "scripts/**/*", "prisma/seed.ts"]
}

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

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { router } from './routes'; import { router } from './routes';
import { useAuthStore } from './features/auth/authStore'; import { useAuthStore } from './features/auth/authStore';
import { I18nProvider } from './i18n/i18n';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -24,11 +25,13 @@ function AuthBoot({ children }: { children: React.ReactNode }) {
export function App() { export function App() {
return ( return (
<I18nProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthBoot> <AuthBoot>
<RouterProvider router={router} /> <RouterProvider router={router} />
</AuthBoot> </AuthBoot>
<Toaster position="top-center" richColors closeButton /> <Toaster position="top-center" richColors closeButton />
</QueryClientProvider> </QueryClientProvider>
</I18nProvider>
); );
} }

View File

@@ -0,0 +1,39 @@
import { Languages } from 'lucide-react';
import { useI18n, type Language } from '@/i18n/i18n';
import { cn } from '@/lib/cn';
const languages: Array<{ value: Language; label: string }> = [
{ value: 'ru', label: 'RU' },
{ value: 'en', label: 'EN' },
];
export function LanguageSwitcher({ className }: { className?: string }) {
const { language, setLanguage, t } = useI18n();
return (
<div
className={cn(
'language-switcher',
className,
)}
aria-label={t('language.switch')}
>
<Languages className="language-switcher__icon" aria-hidden />
{languages.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setLanguage(item.value)}
className={cn(
'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')}
>
{item.label}
</button>
))}
</div>
);
}

View File

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

View File

@@ -2,12 +2,14 @@ import { useQuery } from '@tanstack/react-query';
import { Gift } from 'lucide-react'; import { Gift } from 'lucide-react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { FRONTEND_VERSION } from '@/lib/version'; import { FRONTEND_VERSION } from '@/lib/version';
import { useI18n } from '@/i18n/i18n';
interface VersionInfo { interface VersionInfo {
backend: string; backend: string;
} }
export function Footer() { export function Footer() {
const { t } = useI18n();
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['version'], queryKey: ['version'],
queryFn: () => api.get<VersionInfo>('/api/version'), queryFn: () => api.get<VersionInfo>('/api/version'),
@@ -15,16 +17,16 @@ export function Footer() {
}); });
return ( return (
<footer className="container-page mt-10 py-6 text-xs text-muted"> <footer className="app-footer">
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row"> <div className="app-footer__inner">
<div className="flex items-center gap-2"> <div className="app-footer__brand">
<Gift className="h-4 w-4" aria-hidden /> <Gift className="h-4 w-4" aria-hidden />
<span className="font-display text-sm">Family Wishlist</span> <span className="app-footer__brand-name">{t('app.name')}</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="app-footer__meta">
<span>frontend v{FRONTEND_VERSION}</span> <span>{t('footer.frontend', { version: FRONTEND_VERSION })}</span>
<span className="opacity-50">·</span> <span className="app-footer__separator">·</span>
<span>backend v{data?.backend ?? '...'}</span> <span>{t('footer.backend', { version: data?.backend ?? '...' })}</span>
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -1,39 +1,93 @@
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 { 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 { LanguageSwitcher } from '../LanguageSwitcher';
import { api } from '@/lib/api';
type NavIcon = ComponentType<{ className?: string }>;
interface FriendProfile {
slug: string;
displayName: string;
avatarUrl: string | null;
}
const links = [ const links = [
{ to: '/', label: 'Active', icon: Sparkles, end: true }, { to: '/', label: 'header.active', icon: Sparkles, end: true },
{ to: '/completed', label: 'Fulfilled', icon: CheckCircle2 }, { to: '/completed', label: 'header.fulfilled', icon: CheckCircle2 },
{ to: '/archive', label: 'Archive', icon: Archive }, { to: '/archive', label: 'header.archive', icon: Archive },
{ to: '/trash', label: 'Trash', icon: Trash2 }, { to: '/trash', label: 'header.trash', icon: Trash2 },
]; ] satisfies Array<{
to: string;
label: TranslationKey;
icon: NavIcon;
end?: boolean;
}>;
export function Header() { export function Header() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate(); 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; if (!user) return null;
return ( return (
<header className="container-page pt-6"> <header className="app-header">
<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="app-header__inner">
<Link to="/" className="flex items-center gap-2"> <Link to="/" className="app-header__brand">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card"> <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="font-display text-lg leading-tight">Family Wishlist</div> <div className="app-header__brand-title">{t('app.name')}</div>
<div className="text-xs text-muted"> <div className="app-header__brand-subtitle">
signed in as <span className="font-medium text-ink">{user.displayName}</span> {t('header.signedInAs', { name: user.displayName })}
</div> </div>
</div> </div>
</Link> </Link>
<nav className="flex flex-wrap items-center gap-1"> <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) => ( {links.map((l) => (
<NavLink <NavLink
key={l.to} key={l.to}
@@ -41,39 +95,30 @@ export function Header() {
end={l.end} end={l.end}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors', 'app-header__nav-link',
isActive ? 'bg-primary text-primary-foreground shadow-card' : 'text-ink hover:bg-ink/5', isActive && 'app-header__nav-link--active',
) )
} }
> >
<l.icon className="h-4 w-4" /> <l.icon className="h-4 w-4" />
{l.label} {t(l.label)}
</NavLink> </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> </nav>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/settings')}
title="Profile settings"
>
<UserCog className="h-4 w-4" />
Profile
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
void logout().then(() => navigate('/login'));
}}
>
<LogOut className="h-4 w-4" />
Log out
</Button>
</div>
</div>
</header> </header>
); );
} }

View File

@@ -1,13 +1,17 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useAuthStore } from '@/features/auth/authStore'; import { useAuthStore } from '@/features/auth/authStore';
import { useI18n } from '@/i18n/i18n';
export function ProtectedRoute({ children }: { children: ReactNode }) { export function ProtectedRoute({ children }: { children: ReactNode }) {
const { user, status } = useAuthStore(); const { user, status } = useAuthStore();
const location = useLocation(); const location = useLocation();
const { t } = useI18n();
if (status !== 'ready') { if (status !== 'ready') {
return ( return (
<div className="flex min-h-[50vh] items-center justify-center text-muted">Loading...</div> <div className="flex min-h-[50vh] items-center justify-center text-muted">
{t('protected.loading')}
</div>
); );
} }
if (!user) { if (!user) {

View File

@@ -1,6 +1,7 @@
import { Check, Sparkles, Archive, Trash2 } from 'lucide-react'; import { Check, Sparkles, Archive, Trash2 } from 'lucide-react';
import type { Wish } from '@family-wishlist/shared'; import type { Wish } from '@family-wishlist/shared';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { useI18n } from '@/i18n/i18n';
interface Props { interface Props {
wish: Wish & { isNewForOwner?: boolean }; wish: Wish & { isNewForOwner?: boolean };
@@ -9,6 +10,7 @@ interface Props {
} }
export function WishBadges({ wish, view, className }: Props) { export function WishBadges({ wish, view, className }: Props) {
const { t } = useI18n();
const badges: JSX.Element[] = []; const badges: JSX.Element[] = [];
const isNew = const isNew =
@@ -17,7 +19,7 @@ export function WishBadges({ wish, view, className }: Props) {
badges.push( badges.push(
<span className="wish-badge wish-badge--new" key="new"> <span className="wish-badge wish-badge--new" key="new">
<Sparkles className="h-3 w-3" aria-hidden /> <Sparkles className="h-3 w-3" aria-hidden />
new {t('wish.badge.new')}
</span>, </span>,
); );
} }
@@ -25,7 +27,7 @@ export function WishBadges({ wish, view, className }: Props) {
badges.push( badges.push(
<span className="wish-badge wish-badge--completed" key="done"> <span className="wish-badge wish-badge--completed" key="done">
<Check className="h-3 w-3" aria-hidden /> <Check className="h-3 w-3" aria-hidden />
fulfilled {t('wish.badge.fulfilled')}
</span>, </span>,
); );
} }
@@ -33,7 +35,7 @@ export function WishBadges({ wish, view, className }: Props) {
badges.push( badges.push(
<span className="wish-badge wish-badge--archived" key="arch"> <span className="wish-badge wish-badge--archived" key="arch">
<Archive className="h-3 w-3" aria-hidden /> <Archive className="h-3 w-3" aria-hidden />
archived {t('wish.badge.archived')}
</span>, </span>,
); );
} }
@@ -41,7 +43,7 @@ export function WishBadges({ wish, view, className }: Props) {
badges.push( badges.push(
<span className="wish-badge wish-badge--deleted" key="del"> <span className="wish-badge wish-badge--deleted" key="del">
<Trash2 className="h-3 w-3" aria-hidden /> <Trash2 className="h-3 w-3" aria-hidden />
trash {t('wish.badge.trash')}
</span>, </span>,
); );
} }

View File

@@ -14,6 +14,7 @@ import { WishBadges } from '../WishBadges/WishBadges';
import { Button } from '../ui/Button'; import { Button } from '../ui/Button';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { formatPrice } from '@/lib/format'; import { formatPrice } from '@/lib/format';
import { useI18n } from '@/i18n/i18n';
export type WishCardView = 'owner' | 'guest'; export type WishCardView = 'owner' | 'guest';
@@ -49,39 +50,40 @@ function WishCardInner({
footer, footer,
}: WishCardProps) { }: WishCardProps) {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const { locale, t } = useI18n();
const completed = wish.status === 'COMPLETED'; const completed = wish.status === 'COMPLETED';
const priceLabel = formatPrice(wish.price, wish.currency); const priceLabel = formatPrice(wish.price, wish.currency, locale);
const imageSrc = wish.imageUrl ?? '/default-gift.svg'; const imageSrc = wish.imageUrl ?? '/default-gift.svg';
const actions: WishCardAction[] = []; const actions: WishCardAction[] = [];
if (view === 'owner') { if (view === 'owner') {
if (wish.status === 'ACTIVE') { if (wish.status === 'ACTIVE') {
if (onEdit) actions.push({ key: 'edit', icon: Pencil, label: 'Edit', onClick: onEdit }); if (onEdit) actions.push({ key: 'edit', icon: Pencil, label: t('wish.action.edit'), onClick: onEdit });
if (onComplete) if (onComplete)
actions.push({ actions.push({
key: 'complete', key: 'complete',
icon: CheckCircle2, icon: CheckCircle2,
label: 'Mark fulfilled', label: t('wish.action.complete'),
onClick: onComplete, onClick: onComplete,
}); });
if (onArchive) if (onArchive)
actions.push({ key: 'archive', icon: Archive, label: 'Archive', onClick: onArchive }); actions.push({ key: 'archive', icon: Archive, label: t('wish.action.archive'), onClick: onArchive });
if (onDelete) if (onDelete)
actions.push({ actions.push({
key: 'delete', key: 'delete',
icon: Trash2, icon: Trash2,
label: 'Delete', label: t('wish.action.delete'),
onClick: onDelete, onClick: onDelete,
danger: true, danger: true,
}); });
} else if (wish.status === 'ARCHIVED') { } else if (wish.status === 'ARCHIVED') {
if (onRestore) if (onRestore)
actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore }); actions.push({ key: 'restore', icon: RotateCcw, label: t('wish.action.restore'), onClick: onRestore });
if (onDelete) if (onDelete)
actions.push({ actions.push({
key: 'delete', key: 'delete',
icon: Trash2, icon: Trash2,
label: 'Delete', label: t('wish.action.delete'),
onClick: onDelete, onClick: onDelete,
danger: true, danger: true,
}); });
@@ -90,20 +92,20 @@ function WishCardInner({
actions.push({ actions.push({
key: 'duplicate', key: 'duplicate',
icon: Copy, icon: Copy,
label: 'Create copy as new', label: t('wish.action.duplicate'),
onClick: onDuplicate, onClick: onDuplicate,
}); });
if (onDelete) if (onDelete)
actions.push({ actions.push({
key: 'delete', key: 'delete',
icon: Trash2, icon: Trash2,
label: 'Delete', label: t('wish.action.delete'),
onClick: onDelete, onClick: onDelete,
danger: true, danger: true,
}); });
} else if (wish.status === 'DELETED') { } else if (wish.status === 'DELETED') {
if (onRestore) if (onRestore)
actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore }); actions.push({ key: 'restore', icon: RotateCcw, label: t('wish.action.restore'), onClick: onRestore });
} }
} }
@@ -128,7 +130,7 @@ function WishCardInner({
<Button <Button
variant="secondary" variant="secondary"
size="icon" size="icon"
aria-label="Actions" aria-label={t('wish.actions')}
onClick={() => setMenuOpen((v) => !v)} onClick={() => setMenuOpen((v) => !v)}
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
@@ -177,7 +179,7 @@ function WishCardInner({
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary-600" className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary-600"
> >
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
open link {t('wish.openLink')}
</a> </a>
)} )}
</div> </div>

View File

@@ -15,6 +15,7 @@ import {
useUpdateWish, useUpdateWish,
useUploadWishImage, useUploadWishImage,
} from '@/features/wishes/wishes.hooks'; } from '@/features/wishes/wishes.hooks';
import { translateValidation, useI18n } from '@/i18n/i18n';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -24,6 +25,7 @@ interface Props {
} }
export function WishForm({ open, mode, initial, onClose }: Props) { export function WishForm({ open, mode, initial, onClose }: Props) {
const { t } = useI18n();
const create = useCreateWish(); const create = useCreateWish();
const update = useUpdateWish(); const update = useUpdateWish();
const upload = useUploadWishImage(); const upload = useUploadWishImage();
@@ -78,59 +80,74 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
<Modal <Modal
open={open} open={open}
onClose={onClose} onClose={onClose}
title={mode === 'create' ? 'Add a wish' : 'Edit wish'} title={mode === 'create' ? t('wishForm.addTitle') : t('wishForm.editTitle')}
description={ description={
mode === 'create' mode === 'create'
? 'Tell us what you want. A link helps us grab a preview image automatically.' ? t('wishForm.addDescription')
: 'Update the details of your wish.' : t('wishForm.editDescription')
} }
size="lg" size="lg"
footer={ footer={
<> <>
<Button variant="ghost" onClick={onClose}> <Button variant="ghost" onClick={onClose}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button type="submit" form="wish-form" disabled={isSubmitting}> <Button type="submit" form="wish-form" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />} {isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
{mode === 'create' ? 'Add wish' : 'Save'} {mode === 'create' ? t('wishForm.addSubmit') : t('common.save')}
</Button> </Button>
</> </>
} }
> >
<form id="wish-form" className="grid gap-4" onSubmit={submit}> <form id="wish-form" className="grid gap-4" onSubmit={submit}>
<div className="field"> <div className="field">
<Label htmlFor="title">Title</Label> <Label htmlFor="title">{t('wishForm.title')}</Label>
<Input id="title" placeholder="Moka pot, size 3" {...register('title')} /> <Input id="title" placeholder={t('wishForm.titlePlaceholder')} {...register('title')} />
{errors.title && <span className="field__error">{errors.title.message}</span>} {errors.title && (
<span className="field__error">{translateValidation(t, errors.title.message)}</span>
)}
</div> </div>
<div className="grid gap-4 sm:grid-cols-[1fr_auto]"> <div className="grid gap-4 sm:grid-cols-[1fr_auto]">
<div className="field"> <div className="field">
<Label htmlFor="price">Price (optional)</Label> <Label htmlFor="price">{t('wishForm.price')}</Label>
<Input id="price" placeholder="e.g. 2490" inputMode="decimal" {...register('price')} /> <Input
{errors.price && <span className="field__error">{errors.price.message as string}</span>} id="price"
placeholder={t('wishForm.pricePlaceholder')}
inputMode="decimal"
{...register('price')}
/>
{errors.price && (
<span className="field__error">
{translateValidation(t, errors.price.message as string)}
</span>
)}
</div> </div>
<div className="field"> <div className="field">
<Label htmlFor="currency">Currency</Label> <Label htmlFor="currency">{t('wishForm.currency')}</Label>
<Input id="currency" maxLength={3} className="uppercase w-24" {...register('currency')} /> <Input id="currency" maxLength={3} className="uppercase w-24" {...register('currency')} />
</div> </div>
</div> </div>
<div className="field"> <div className="field">
<Label htmlFor="url">Link (optional)</Label> <Label htmlFor="url">{t('wishForm.link')}</Label>
<Input id="url" type="url" placeholder="https://..." {...register('url')} /> <Input id="url" type="url" placeholder="https://..." {...register('url')} />
{errors.url && <span className="field__error">{errors.url.message as string}</span>} {errors.url && (
<span className="field__error">
{translateValidation(t, errors.url.message as string)}
</span>
)}
<p className="text-xs text-muted"> <p className="text-xs text-muted">
We will try to pull a preview image from the link after saving. {t('wishForm.linkHint')}
</p> </p>
</div> </div>
<div className="field"> <div className="field">
<Label htmlFor="comment">Comment (optional)</Label> <Label htmlFor="comment">{t('wishForm.comment')}</Label>
<Textarea <Textarea
id="comment" id="comment"
rows={3} rows={3}
placeholder="Size / color / notes..." placeholder={t('wishForm.commentPlaceholder')}
{...register('comment')} {...register('comment')}
/> />
</div> </div>
@@ -140,7 +157,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
<section className="mt-6 rounded-md border border-border bg-surface-muted p-4"> <section className="mt-6 rounded-md border border-border bg-surface-muted p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-ink"> <div className="mb-3 flex items-center gap-2 text-sm font-medium text-ink">
<ImageIcon className="h-4 w-4" /> <ImageIcon className="h-4 w-4" />
Image {t('wishForm.image')}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<input <input
@@ -165,7 +182,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
) : ( ) : (
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4" />
)} )}
Upload custom {t('wishForm.uploadCustom')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -178,7 +195,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
) : ( ) : (
<RefreshCcw className="h-4 w-4" /> <RefreshCcw className="h-4 w-4" />
)} )}
Refresh from link {t('wishForm.refreshFromLink')}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -187,7 +204,7 @@ export function WishForm({ open, mode, initial, onClose }: Props) {
disabled={resetImage.isPending} disabled={resetImage.isPending}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Reset to default {t('wishForm.resetImage')}
</Button> </Button>
</div> </div>
</section> </section>

View File

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

View File

@@ -3,6 +3,7 @@ import type { CreateWishInput, UpdateWishInput } from '@family-wishlist/shared';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { wishesApi, type OwnerStatus } from './wishes.api'; import { wishesApi, type OwnerStatus } from './wishes.api';
import { ApiError } from '@/lib/api'; import { ApiError } from '@/lib/api';
import { useI18n } from '@/i18n/i18n';
const LIST_KEY = (status: OwnerStatus) => ['wishes', status] as const; const LIST_KEY = (status: OwnerStatus) => ['wishes', status] as const;
@@ -10,7 +11,7 @@ function invalidateAll(client: ReturnType<typeof useQueryClient>): void {
void client.invalidateQueries({ queryKey: ['wishes'] }); void client.invalidateQueries({ queryKey: ['wishes'] });
} }
function toastError(err: unknown, fallback = 'Something went wrong'): void { function toastError(err: unknown, fallback: string): void {
if (err instanceof ApiError) toast.error(err.message); if (err instanceof ApiError) toast.error(err.message);
else toast.error(fallback); else toast.error(fallback);
} }
@@ -25,121 +26,131 @@ export function useWishes(status: OwnerStatus) {
export function useCreateWish() { export function useCreateWish() {
const client = useQueryClient(); const client = useQueryClient();
const { t } = useI18n();
return useMutation({ return useMutation({
mutationFn: (input: CreateWishInput) => wishesApi.create(input), mutationFn: (input: CreateWishInput) => wishesApi.create(input),
onSuccess: () => { onSuccess: () => {
invalidateAll(client); invalidateAll(client);
toast.success('Wish added'); toast.success(t('toast.wishAdded'));
}, },
onError: (err) => toastError(err), onError: (err) => toastError(err, t('toast.genericError')),
}); });
} }
export function useUpdateWish() { export function useUpdateWish() {
const client = useQueryClient(); const client = useQueryClient();
const { t } = useI18n();
return useMutation({ return useMutation({
mutationFn: (vars: { id: string; input: UpdateWishInput }) => mutationFn: (vars: { id: string; input: UpdateWishInput }) =>
wishesApi.update(vars.id, vars.input), wishesApi.update(vars.id, vars.input),
onSuccess: () => { onSuccess: () => {
invalidateAll(client); invalidateAll(client);
toast.success('Saved'); toast.success(t('toast.saved'));
}, },
onError: (err) => toastError(err), onError: (err) => toastError(err, t('toast.genericError')),
}); });
} }
export function useArchiveWish() { export function useArchiveWish() {
const client = useQueryClient(); const client = useQueryClient();
const { t } = useI18n();
return useMutation({ return useMutation({
mutationFn: (id: string) => wishesApi.archive(id), mutationFn: (id: string) => wishesApi.archive(id),
onSuccess: () => { onSuccess: () => {
invalidateAll(client); invalidateAll(client);
toast.success('Moved to archive'); toast.success(t('toast.archived'));
}, },
onError: (err) => toastError(err), onError: (err) => toastError(err, t('toast.genericError')),
}); });
} }
export function useCompleteWish() { export function useCompleteWish() {
const client = useQueryClient(); const client = useQueryClient();
const { t } = useI18n();
return useMutation({ return useMutation({
mutationFn: (id: string) => wishesApi.complete(id), mutationFn: (id: string) => wishesApi.complete(id),
onSuccess: () => { onSuccess: () => {
invalidateAll(client); invalidateAll(client);
toast.success('Marked as fulfilled'); toast.success(t('toast.fulfilled'));
}, },
onError: (err) => toastError(err), onError: (err) => toastError(err, t('toast.genericError')),
}); });
} }
export function useDeleteWish() { export function useDeleteWish() {
const client = useQueryClient(); const client = useQueryClient();
const { t } = useI18n();
return useMutation({ return useMutation({
mutationFn: (id: string) => wishesApi.remove(id), mutationFn: (id: string) => wishesApi.remove(id),
onSuccess: () => { onSuccess: () => {
invalidateAll(client); invalidateAll(client);
toast.success('Moved to trash (30 days to restore)'); toast.success(t('toast.deleted'));
}, },
onError: (err) => toastError(err), onError: (err) => toastError(err, t('toast.genericError')),
}); });
} }
export function useRestoreWish() { export function useRestoreWish() {
const client = useQueryClient(); const client = useQueryClient();
const { t } = useI18n();
return useMutation({ return useMutation({
mutationFn: (id: string) => wishesApi.restore(id), mutationFn: (id: string) => wishesApi.restore(id),
onSuccess: () => { onSuccess: () => {
invalidateAll(client); invalidateAll(client);
toast.success('Restored'); toast.success(t('toast.restored'));
}, },
onError: (err) => toastError(err), onError: (err) => toastError(err, t('toast.genericError')),
}); });
} }
export function useDuplicateWish() { export function useDuplicateWish() {
const client = useQueryClient(); const client = useQueryClient();
const { t } = useI18n();
return useMutation({ return useMutation({
mutationFn: (id: string) => wishesApi.duplicate(id), mutationFn: (id: string) => wishesApi.duplicate(id),
onSuccess: () => { onSuccess: () => {
invalidateAll(client); invalidateAll(client);
toast.success('New wish created from the fulfilled one'); toast.success(t('toast.duplicated'));
}, },
onError: (err) => toastError(err), onError: (err) => toastError(err, t('toast.genericError')),
}); });
} }
export function useUploadWishImage() { export function useUploadWishImage() {
const client = useQueryClient(); const client = useQueryClient();
const { t } = useI18n();
return useMutation({ return useMutation({
mutationFn: (vars: { id: string; file: File }) => wishesApi.uploadImage(vars.id, vars.file), mutationFn: (vars: { id: string; file: File }) => wishesApi.uploadImage(vars.id, vars.file),
onSuccess: () => { onSuccess: () => {
invalidateAll(client); invalidateAll(client);
toast.success('Image updated'); toast.success(t('toast.imageUpdated'));
}, },
onError: (err) => toastError(err), onError: (err) => toastError(err, t('toast.genericError')),
}); });
} }
export function useRefreshOg() { export function useRefreshOg() {
const client = useQueryClient(); const client = useQueryClient();
const { t } = useI18n();
return useMutation({ return useMutation({
mutationFn: (id: string) => wishesApi.refreshOg(id), mutationFn: (id: string) => wishesApi.refreshOg(id),
onSuccess: () => { onSuccess: () => {
invalidateAll(client); invalidateAll(client);
toast.success('Image refreshed from link'); toast.success(t('toast.imageRefreshed'));
}, },
onError: (err) => toastError(err, 'Could not fetch image from link'), onError: (err) => toastError(err, t('toast.imageFetchFailed')),
}); });
} }
export function useResetWishImage() { export function useResetWishImage() {
const client = useQueryClient(); const client = useQueryClient();
const { t } = useI18n();
return useMutation({ return useMutation({
mutationFn: (id: string) => wishesApi.deleteImage(id), mutationFn: (id: string) => wishesApi.deleteImage(id),
onSuccess: () => { onSuccess: () => {
invalidateAll(client); invalidateAll(client);
toast.success('Reset to default image'); toast.success(t('toast.imageReset'));
}, },
onError: (err) => toastError(err), onError: (err) => toastError(err, t('toast.genericError')),
}); });
} }

View File

@@ -0,0 +1,339 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
export type Language = 'ru' | 'en';
const STORAGE_KEY = 'family-wishlist-language';
const FALLBACK_LANGUAGE: Language = 'ru';
const translations = {
ru: {
'app.name': 'Family Wishlist',
'language.ru': 'Русский',
'language.en': 'English',
'language.switch': 'Язык',
'common.loading': 'Загрузка...',
'common.cancel': 'Отмена',
'common.save': 'Сохранить',
'common.saveChanges': 'Сохранить изменения',
'common.backHome': 'На главную',
'common.days.one': '{count} день',
'common.days.few': '{count} дня',
'common.days.many': '{count} дней',
'footer.frontend': 'frontend v{version}',
'footer.backend': 'backend v{version}',
'header.active': 'Активные',
'header.fulfilled': 'Исполненные',
'header.archive': 'Архив',
'header.trash': 'Корзина',
'header.signedInAs': 'вошли как {name}',
'header.profileSettings': 'Настройки профиля',
'header.profile': 'Профиль',
'header.friendWishes': 'Желания {name}',
'header.logout': 'Выйти',
'login.title': 'С возвращением',
'login.description': 'Войдите, чтобы управлять списком желаний. Доступы задаются на сервере.',
'login.username': 'Логин',
'login.password': 'Пароль',
'login.submit': 'Войти',
'login.failed': 'Не удалось войти',
'dashboard.title': 'Ваш список желаний',
'dashboard.description': 'Добавляйте вещи, о которых мечтаете. Публичная страница доступна по адресу ',
'dashboard.addWish': 'Добавить желание',
'dashboard.emptyTitle': 'Желаний пока нет',
'dashboard.emptyText': 'Начните с того, что вам действительно хочется получить.',
'dashboard.addFirstWish': 'Добавить первое желание',
'archive.title': 'Архив',
'archive.description': 'Отложенные желания. Их видите только вы, и их можно вернуть в активный список.',
'archive.emptyTitle': 'Архив пуст',
'archive.emptyText': 'Архивированные желания появятся здесь.',
'completed.title': 'Исполненные',
'completed.description': 'Желания, которые уже исполнились. Из любого можно создать новое.',
'completed.emptyTitle': 'Исполненных желаний пока нет',
'completed.emptyText': 'Когда желание исполнится, отметьте его, и оно появится здесь.',
'trash.title': 'Корзина',
'trash.description': 'Удалённые желания хранятся {days}, затем удаляются навсегда.',
'trash.emptyTitle': 'Корзина пуста',
'trash.emptyText': 'Удалённые желания будут появляться здесь на {days}.',
'trash.autoRemove': 'Автоудаление через {days}',
'profile.title': 'Профиль',
'profile.publicPage': 'Публичная страница доступна по адресу ',
'profile.slug': 'Slug (публичный URL)',
'profile.displayName': 'Отображаемое имя',
'profile.bio': 'О себе',
'profile.avatarUrl': 'Ссылка на аватар',
'profile.avatar': 'Аватар',
'profile.avatarHint': 'Можно вставить ссылку или загрузить фото до 2 MB.',
'profile.uploadAvatar': 'Загрузить фото',
'profile.avatarTooLarge': 'Фото должно быть не больше 2 MB',
'profile.avatarUnsupported': 'Поддерживаются JPEG, PNG, WebP и GIF',
'profile.saved': 'Профиль сохранён',
'profile.avatarUploaded': 'Аватар обновлён',
'profile.saveFailed': 'Не удалось сохранить',
'public.loadingWishes': 'Загрузка желаний...',
'public.notFoundTitle': 'Профиль не найден',
'public.notFoundText': 'Проверьте ссылку и попробуйте ещё раз. Slug чувствителен к регистру.',
'public.backToMine': 'Вернуться к моим желаниям',
'public.wishlistTitle': 'Список желаний {name}',
'public.emptyTitle': 'Желаний пока нет',
'public.emptyText': 'Загляните позже!',
'notFound.text': 'Не удалось найти эту страницу.',
'protected.loading': 'Загрузка...',
'wish.action.edit': 'Редактировать',
'wish.action.complete': 'Отметить исполненным',
'wish.action.archive': 'В архив',
'wish.action.delete': 'Удалить',
'wish.action.restore': 'Восстановить',
'wish.action.duplicate': 'Создать копию как новое',
'wish.actions': 'Действия',
'wish.openLink': 'открыть ссылку',
'wish.badge.new': 'новое',
'wish.badge.fulfilled': 'исполнено',
'wish.badge.archived': 'архив',
'wish.badge.trash': 'корзина',
'wishForm.addTitle': 'Добавить желание',
'wishForm.editTitle': 'Редактировать желание',
'wishForm.addDescription': 'Расскажите, чего хочется. По ссылке мы попробуем подтянуть картинку.',
'wishForm.editDescription': 'Обновите детали желания.',
'wishForm.title': 'Название',
'wishForm.titlePlaceholder': 'Гейзерная кофеварка, размер 3',
'wishForm.price': 'Цена (необязательно)',
'wishForm.pricePlaceholder': 'например, 2490',
'wishForm.currency': 'Валюта',
'wishForm.link': 'Ссылка (необязательно)',
'wishForm.linkHint': 'После сохранения попробуем подтянуть превью-картинку по ссылке.',
'wishForm.comment': 'Комментарий (необязательно)',
'wishForm.commentPlaceholder': 'Размер / цвет / заметки...',
'wishForm.image': 'Картинка',
'wishForm.uploadCustom': 'Загрузить свою',
'wishForm.refreshFromLink': 'Обновить по ссылке',
'wishForm.resetImage': 'Сбросить на стандартную',
'wishForm.addSubmit': 'Добавить желание',
'toast.genericError': 'Что-то пошло не так',
'toast.wishAdded': 'Желание добавлено',
'toast.saved': 'Сохранено',
'toast.archived': 'Перемещено в архив',
'toast.fulfilled': 'Отмечено исполненным',
'toast.deleted': 'Перемещено в корзину (30 дней на восстановление)',
'toast.restored': 'Восстановлено',
'toast.duplicated': 'Новое желание создано из исполненного',
'toast.imageUpdated': 'Картинка обновлена',
'toast.imageRefreshed': 'Картинка обновлена по ссылке',
'toast.imageFetchFailed': 'Не удалось получить картинку по ссылке',
'toast.imageReset': 'Картинка сброшена на стандартную',
'validation.invalid': 'Проверьте значение поля',
'validation.slug': 'Slug: 3-32 символа, строчные латинские буквы, цифры и дефисы',
},
en: {
'app.name': 'Family Wishlist',
'language.ru': 'Русский',
'language.en': 'English',
'language.switch': 'Language',
'common.loading': 'Loading...',
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.saveChanges': 'Save changes',
'common.backHome': 'Back to home',
'common.days.one': '{count} day',
'common.days.few': '{count} days',
'common.days.many': '{count} days',
'footer.frontend': 'frontend v{version}',
'footer.backend': 'backend v{version}',
'header.active': 'Active',
'header.fulfilled': 'Fulfilled',
'header.archive': 'Archive',
'header.trash': 'Trash',
'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.',
'login.username': 'Username',
'login.password': 'Password',
'login.submit': 'Sign in',
'login.failed': 'Login failed',
'dashboard.title': 'Your wishlist',
'dashboard.description': 'Add things you dream of. Share your public page at ',
'dashboard.addWish': 'Add wish',
'dashboard.emptyTitle': 'No wishes yet',
'dashboard.emptyText': "Start by adding something you'd love to receive.",
'dashboard.addFirstWish': 'Add your first wish',
'archive.title': 'Archive',
'archive.description': 'Wishes you put aside. Only you see this. Restore them to your active list any time.',
'archive.emptyTitle': 'Archive is empty',
'archive.emptyText': 'Archived wishes will show up here.',
'completed.title': 'Fulfilled',
'completed.description': "Wishes you've received. You can create a new wish based on any of them.",
'completed.emptyTitle': 'Nothing fulfilled yet',
'completed.emptyText': 'When a wish comes true, mark it as fulfilled and it lands here.',
'trash.title': 'Trash',
'trash.description': 'Deleted wishes are kept for {days}, then permanently removed.',
'trash.emptyTitle': 'Trash is empty',
'trash.emptyText': 'Deleted wishes will appear here for {days}.',
'trash.autoRemove': 'Auto-removes in {days}',
'profile.title': 'Profile',
'profile.publicPage': 'Your public page lives at ',
'profile.slug': 'Slug (public URL)',
'profile.displayName': 'Display name',
'profile.bio': 'Bio',
'profile.avatarUrl': 'Avatar URL',
'profile.avatar': 'Avatar',
'profile.avatarHint': 'Paste a link or upload a photo up to 2 MB.',
'profile.uploadAvatar': 'Upload photo',
'profile.avatarTooLarge': 'Avatar must be 2 MB or less',
'profile.avatarUnsupported': 'JPEG, PNG, WebP and GIF are supported',
'profile.saved': 'Profile saved',
'profile.avatarUploaded': 'Avatar updated',
'profile.saveFailed': 'Save failed',
'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!',
'notFound.text': "We couldn't find that page.",
'protected.loading': 'Loading...',
'wish.action.edit': 'Edit',
'wish.action.complete': 'Mark fulfilled',
'wish.action.archive': 'Archive',
'wish.action.delete': 'Delete',
'wish.action.restore': 'Restore',
'wish.action.duplicate': 'Create copy as new',
'wish.actions': 'Actions',
'wish.openLink': 'open link',
'wish.badge.new': 'new',
'wish.badge.fulfilled': 'fulfilled',
'wish.badge.archived': 'archived',
'wish.badge.trash': 'trash',
'wishForm.addTitle': 'Add a wish',
'wishForm.editTitle': 'Edit wish',
'wishForm.addDescription': 'Tell us what you want. A link helps us grab a preview image automatically.',
'wishForm.editDescription': 'Update the details of your wish.',
'wishForm.title': 'Title',
'wishForm.titlePlaceholder': 'Moka pot, size 3',
'wishForm.price': 'Price (optional)',
'wishForm.pricePlaceholder': 'e.g. 2490',
'wishForm.currency': 'Currency',
'wishForm.link': 'Link (optional)',
'wishForm.linkHint': 'We will try to pull a preview image from the link after saving.',
'wishForm.comment': 'Comment (optional)',
'wishForm.commentPlaceholder': 'Size / color / notes...',
'wishForm.image': 'Image',
'wishForm.uploadCustom': 'Upload custom',
'wishForm.refreshFromLink': 'Refresh from link',
'wishForm.resetImage': 'Reset to default',
'wishForm.addSubmit': 'Add wish',
'toast.genericError': 'Something went wrong',
'toast.wishAdded': 'Wish added',
'toast.saved': 'Saved',
'toast.archived': 'Moved to archive',
'toast.fulfilled': 'Marked as fulfilled',
'toast.deleted': 'Moved to trash (30 days to restore)',
'toast.restored': 'Restored',
'toast.duplicated': 'New wish created from the fulfilled one',
'toast.imageUpdated': 'Image updated',
'toast.imageRefreshed': 'Image refreshed from link',
'toast.imageFetchFailed': 'Could not fetch image from link',
'toast.imageReset': 'Reset to default image',
'validation.invalid': 'Check this field',
'validation.slug': 'Slug must be 3-32 chars, lowercase letters, digits, hyphens',
},
} as const;
export type TranslationKey = keyof typeof translations.ru;
interface I18nContextValue {
language: Language;
locale: string;
setLanguage: (language: Language) => void;
t: (key: TranslationKey, vars?: Record<string, string | number>) => string;
dayCount: (count: number) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
function isLanguage(value: string | null): value is Language {
return value === 'ru' || value === 'en';
}
function detectLanguage(): Language {
if (typeof window === 'undefined') return FALLBACK_LANGUAGE;
const saved = window.localStorage.getItem(STORAGE_KEY);
if (isLanguage(saved)) return saved;
const languages = navigator.languages.length ? navigator.languages : [navigator.language];
for (const lang of languages) {
const normalized = lang.toLowerCase();
if (normalized.startsWith('ru')) return 'ru';
if (normalized.startsWith('en')) return 'en';
}
return FALLBACK_LANGUAGE;
}
function interpolate(template: string, vars?: Record<string, string | number>): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (_, key: string) => String(vars[key] ?? `{${key}}`));
}
function dayKey(language: Language, count: number): TranslationKey {
if (language === 'en') return count === 1 ? 'common.days.one' : 'common.days.many';
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return 'common.days.one';
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'common.days.few';
return 'common.days.many';
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(() => detectLanguage());
useEffect(() => {
document.documentElement.lang = language;
window.localStorage.setItem(STORAGE_KEY, language);
}, [language]);
const setLanguage = useCallback((next: Language) => setLanguageState(next), []);
const t = useCallback(
(key: TranslationKey, vars?: Record<string, string | number>) =>
interpolate(translations[language][key] ?? translations[FALLBACK_LANGUAGE][key], vars),
[language],
);
const dayCount = useCallback(
(count: number) => t(dayKey(language, count), { count }),
[language, t],
);
const value = useMemo<I18nContextValue>(
() => ({
language,
locale: language === 'ru' ? 'ru-RU' : 'en-US',
setLanguage,
t,
dayCount,
}),
[dayCount, language, setLanguage, t],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const value = useContext(I18nContext);
if (!value) throw new Error('useI18n must be used within I18nProvider');
return value;
}
export function translateValidation(t: I18nContextValue['t'], message: string | undefined): string {
if (!message) return t('validation.invalid');
if (message.includes('Slug must be')) return t('validation.slug');
return t('validation.invalid');
}

View File

@@ -1,21 +1,25 @@
export function formatPrice(price: string | null | undefined, currency: string): string | null { export function formatPrice(
price: string | null | undefined,
currency: string,
locale?: string,
): string | null {
if (!price) return null; if (!price) return null;
const n = Number(price); const n = Number(price);
if (Number.isNaN(n)) return `${price} ${currency}`; if (Number.isNaN(n)) return `${price} ${currency}`;
try { try {
return new Intl.NumberFormat(undefined, { return new Intl.NumberFormat(locale, {
style: 'currency', style: 'currency',
currency, currency,
maximumFractionDigits: 2, maximumFractionDigits: 2,
}).format(n); }).format(n);
} catch { } catch {
return `${n.toLocaleString()} ${currency}`; return `${n.toLocaleString(locale)} ${currency}`;
} }
} }
export function formatDate(iso: string | Date): string { export function formatDate(iso: string | Date, locale?: string): string {
const d = typeof iso === 'string' ? new Date(iso) : iso; const d = typeof iso === 'string' ? new Date(iso) : iso;
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }); return d.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' });
} }
export function daysLeftUntil(iso: string | Date, retentionDays: number): number { export function daysLeftUntil(iso: string | Date, retentionDays: number): number {

View File

@@ -5,28 +5,30 @@ import {
useWishes, useWishes,
} from '@/features/wishes/wishes.hooks'; } from '@/features/wishes/wishes.hooks';
import { Archive } from 'lucide-react'; import { Archive } from 'lucide-react';
import { useI18n } from '@/i18n/i18n';
export function ArchivePage() { export function ArchivePage() {
const { t } = useI18n();
const { data, isLoading } = useWishes('archived'); const { data, isLoading } = useWishes('archived');
const restore = useRestoreWish(); const restore = useRestoreWish();
const remove = useDeleteWish(); const remove = useDeleteWish();
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
<section> <section className="page-section">
<h1 className="font-display text-3xl">Archive</h1> <h1 className="page-section__title">{t('archive.title')}</h1>
<p className="text-sm text-muted"> <p className="page-section__text">
Wishes you put aside. Only you see this. Restore them to your active list any time. {t('archive.description')}
</p> </p>
</section> </section>
{isLoading && <div className="text-muted">Loading...</div>} {isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!isLoading && data && data.length === 0 && ( {!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"> <div className="empty-state">
<Archive className="h-10 w-10 text-muted" /> <Archive className="empty-state__icon" />
<h2 className="text-xl font-semibold">Archive is empty</h2> <h2 className="empty-state__title">{t('archive.emptyTitle')}</h2>
<p className="text-sm text-muted">Archived wishes will show up here.</p> <p className="empty-state__text">{t('archive.emptyText')}</p>
</div> </div>
)} )}

View File

@@ -5,29 +5,31 @@ import {
useDuplicateWish, useDuplicateWish,
useWishes, useWishes,
} from '@/features/wishes/wishes.hooks'; } from '@/features/wishes/wishes.hooks';
import { useI18n } from '@/i18n/i18n';
export function CompletedPage() { export function CompletedPage() {
const { t } = useI18n();
const { data, isLoading } = useWishes('completed'); const { data, isLoading } = useWishes('completed');
const duplicate = useDuplicateWish(); const duplicate = useDuplicateWish();
const remove = useDeleteWish(); const remove = useDeleteWish();
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
<section> <section className="page-section">
<h1 className="font-display text-3xl">Fulfilled</h1> <h1 className="page-section__title">{t('completed.title')}</h1>
<p className="text-sm text-muted"> <p className="page-section__text">
Wishes you've received. You can create a new wish based on any of them. {t('completed.description')}
</p> </p>
</section> </section>
{isLoading && <div className="text-muted">Loading...</div>} {isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!isLoading && data && data.length === 0 && ( {!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"> <div className="empty-state">
<CheckCircle2 className="h-10 w-10 text-muted" /> <CheckCircle2 className="empty-state__icon" />
<h2 className="text-xl font-semibold">Nothing fulfilled yet</h2> <h2 className="empty-state__title">{t('completed.emptyTitle')}</h2>
<p className="text-sm text-muted"> <p className="empty-state__text">
When a wish comes true, mark it as fulfilled and it lands here. {t('completed.emptyText')}
</p> </p>
</div> </div>
)} )}

View File

@@ -12,8 +12,10 @@ import {
} from '@/features/wishes/wishes.hooks'; } from '@/features/wishes/wishes.hooks';
import { useAuthStore } from '@/features/auth/authStore'; import { useAuthStore } from '@/features/auth/authStore';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useI18n } from '@/i18n/i18n';
export function DashboardPage() { export function DashboardPage() {
const { t } = useI18n();
const { data, isLoading } = useWishes('active'); const { data, isLoading } = useWishes('active');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<Wish | null>(null); const [editing, setEditing] = useState<Wish | null>(null);
@@ -24,11 +26,11 @@ export function DashboardPage() {
return ( return (
<div className="grid gap-6"> <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> <div>
<h1 className="font-display text-3xl">Your wishlist</h1> <h1 className="page-section__title">{t('dashboard.title')}</h1>
<p className="text-sm text-muted"> <p className="page-section__text">
Add things you dream of. Share your public page at{' '} {t('dashboard.description')}
{user && ( {user && (
<Link <Link
to={`/u/${user.slug}`} to={`/u/${user.slug}`}
@@ -42,7 +44,7 @@ export function DashboardPage() {
</div> </div>
<Button size="lg" onClick={() => setCreating(true)}> <Button size="lg" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add wish {t('dashboard.addWish')}
</Button> </Button>
</section> </section>
@@ -59,17 +61,17 @@ export function DashboardPage() {
)} )}
{!isLoading && data && data.length === 0 && ( {!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"> <div className="empty-state gap-4">
<img src="/empty-state.svg" alt="" className="h-40 w-40 opacity-90" /> <img src="/empty-state.svg" alt="" className="empty-state__icon--image" />
<div> <div>
<h2 className="text-xl font-semibold">No wishes yet</h2> <h2 className="empty-state__title">{t('dashboard.emptyTitle')}</h2>
<p className="mt-1 text-sm text-muted"> <p className="empty-state__text mt-1">
Start by adding something you'd love to receive. {t('dashboard.emptyText')}
</p> </p>
</div> </div>
<Button onClick={() => setCreating(true)}> <Button onClick={() => setCreating(true)}>
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
Add your first wish {t('dashboard.addFirstWish')}
</Button> </Button>
</div> </div>
)} )}

View File

@@ -11,8 +11,11 @@ import { Label } from '@/components/ui/Label';
import { useAuthStore } from '@/features/auth/authStore'; import { useAuthStore } from '@/features/auth/authStore';
import { ApiError } from '@/lib/api'; import { ApiError } from '@/lib/api';
import { Footer } from '@/components/Layout/Footer'; import { Footer } from '@/components/Layout/Footer';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { translateValidation, useI18n } from '@/i18n/i18n';
export function LoginPage() { export function LoginPage() {
const { t } = useI18n();
const { user, login } = useAuthStore(); const { user, login } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -39,29 +42,32 @@ export function LoginPage() {
navigate(from, { replace: true }); navigate(from, { replace: true });
} catch (err) { } catch (err) {
if (err instanceof ApiError) toast.error(err.message); if (err instanceof ApiError) toast.error(err.message);
else toast.error('Login failed'); else toast.error(t('login.failed'));
} }
}); });
return ( return (
<div className="flex min-h-screen flex-col"> <div className="app-shell">
<div className="container-page flex flex-1 items-center justify-center py-12"> <div className="public-profile__toolbar">
<LanguageSwitcher />
</div>
<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="w-full max-w-md animate-fade-in-up">
<div className="mb-6 flex items-center justify-center gap-2"> <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"> <span className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
<Gift className="h-5 w-5" /> <Gift className="h-5 w-5" />
</span> </span>
<h1 className="font-display text-3xl">Family Wishlist</h1> <h1 className="font-display text-3xl">{t('app.name')}</h1>
</div> </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">Welcome back</h2> <h2 className="mb-1 text-xl font-semibold">{t('login.title')}</h2>
<p className="mb-6 text-sm text-muted"> <p className="mb-6 text-sm text-muted">
Sign in to manage your wishlist. Credentials are set up via the server environment. {t('login.description')}
</p> </p>
<form className="grid gap-4" onSubmit={submit}> <form className="grid gap-4" onSubmit={submit}>
<div className="field"> <div className="field">
<Label htmlFor="username">Username</Label> <Label htmlFor="username">{t('login.username')}</Label>
<Input <Input
id="username" id="username"
autoComplete="username" autoComplete="username"
@@ -69,11 +75,13 @@ export function LoginPage() {
{...register('username')} {...register('username')}
/> />
{errors.username && ( {errors.username && (
<span className="field__error">{errors.username.message}</span> <span className="field__error">
{translateValidation(t, errors.username.message)}
</span>
)} )}
</div> </div>
<div className="field"> <div className="field">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">{t('login.password')}</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
@@ -81,12 +89,14 @@ export function LoginPage() {
{...register('password')} {...register('password')}
/> />
{errors.password && ( {errors.password && (
<span className="field__error">{errors.password.message}</span> <span className="field__error">
{translateValidation(t, errors.password.message)}
</span>
)} )}
</div> </div>
<Button type="submit" size="lg" disabled={isSubmitting}> <Button type="submit" size="lg" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />} {isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
Sign in {t('login.submit')}
</Button> </Button>
</form> </form>
</div> </div>

View File

@@ -1,14 +1,16 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useI18n } from '@/i18n/i18n';
export function NotFoundPage() { export function NotFoundPage() {
const { t } = useI18n();
return ( return (
<div className="flex min-h-screen items-center justify-center p-6"> <div className="app-shell 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="empty-state max-w-md bg-surface p-8">
<h1 className="font-display text-4xl">404</h1> <h1 className="font-display text-4xl">404</h1>
<p className="mt-2 text-muted">We couldn't find that page.</p> <p className="mt-2 text-muted">{t('notFound.text')}</p>
<Link to="/" className="mt-4 inline-block"> <Link to="/" className="mt-4 inline-block">
<Button variant="secondary">Back to home</Button> <Button variant="secondary">{t('common.backHome')}</Button>
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useEffect } 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';
@@ -8,17 +8,24 @@ import {
type Profile, type Profile,
} from '@family-wishlist/shared'; } from '@family-wishlist/shared';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Loader2 } from 'lucide-react'; import { Gift, Loader2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label'; import { Label } from '@/components/ui/Label';
import { Textarea } from '@/components/ui/Textarea'; import { Textarea } from '@/components/ui/Textarea';
import { api, ApiError } from '@/lib/api'; import { api, ApiError } from '@/lib/api';
import { useAuthStore } from '@/features/auth/authStore'; import { useAuthStore } from '@/features/auth/authStore';
import { translateValidation, useI18n } from '@/i18n/i18n';
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
const AVATAR_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
export function ProfileSettingsPage() { export function ProfileSettingsPage() {
const { t } = useI18n();
const refresh = useAuthStore((s) => s.refresh); const refresh = useAuthStore((s) => s.refresh);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarFailed, setAvatarFailed] = useState(false);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['profile'], queryKey: ['profile'],
@@ -29,6 +36,7 @@ export function ProfileSettingsPage() {
register, register,
handleSubmit, handleSubmit,
reset, reset,
watch,
formState: { errors, isSubmitting, isDirty }, formState: { errors, isSubmitting, isDirty },
} = useForm<UpdateProfileInput>({ } = useForm<UpdateProfileInput>({
resolver: zodResolver(updateProfileSchema), resolver: zodResolver(updateProfileSchema),
@@ -50,7 +58,7 @@ export function ProfileSettingsPage() {
mutationFn: (values: UpdateProfileInput) => mutationFn: (values: UpdateProfileInput) =>
api.patch<Profile, UpdateProfileInput>('/api/profile', values), api.patch<Profile, UpdateProfileInput>('/api/profile', values),
onSuccess: (p) => { onSuccess: (p) => {
toast.success('Profile saved'); toast.success(t('profile.saved'));
void queryClient.invalidateQueries({ queryKey: ['profile'] }); void queryClient.invalidateQueries({ queryKey: ['profile'] });
void refresh(); void refresh();
reset({ reset({
@@ -62,10 +70,46 @@ export function ProfileSettingsPage() {
}, },
onError: (err) => { onError: (err) => {
if (err instanceof ApiError) toast.error(err.message); if (err instanceof ApiError) toast.error(err.message);
else toast.error('Save failed'); else toast.error(t('profile.saveFailed'));
}, },
}); });
const uploadAvatar = useMutation({
mutationFn: (file: File) => {
const form = new FormData();
form.append('file', file);
return api.upload<Profile>('/api/profile/avatar', form);
},
onSuccess: (p) => {
toast.success(t('profile.avatarUploaded'));
void queryClient.invalidateQueries({ queryKey: ['profile'] });
void refresh();
reset({
slug: p.slug,
displayName: p.displayName,
bio: p.bio ?? '',
avatarUrl: p.avatarUrl ?? '',
});
},
onError: (err) => {
if (err instanceof ApiError) toast.error(err.message);
else toast.error(t('profile.saveFailed'));
},
});
function handleAvatarFile(file: File | undefined): void {
if (!file) return;
if (!AVATAR_MIME_TYPES.has(file.type)) {
toast.error(t('profile.avatarUnsupported'));
return;
}
if (file.size > MAX_AVATAR_BYTES) {
toast.error(t('profile.avatarTooLarge'));
return;
}
uploadAvatar.mutate(file);
}
const submit = handleSubmit((values) => { const submit = handleSubmit((values) => {
const payload: UpdateProfileInput = { const payload: UpdateProfileInput = {
...values, ...values,
@@ -75,43 +119,100 @@ export function ProfileSettingsPage() {
update.mutate(payload); update.mutate(payload);
}); });
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> <section className="page-section">
<h1 className="font-display text-3xl">Profile</h1> <h1 className="page-section__title">{t('profile.title')}</h1>
<p className="text-sm text-muted"> <p className="page-section__text">
Your public page lives at <code className="rounded bg-ink/5 px-1.5 py-0.5">/u/{data?.slug ?? '...'}</code>. {t('profile.publicPage')}
<code className="rounded bg-ink/5 px-1.5 py-0.5">/u/{data?.slug ?? '...'}</code>.
</p> </p>
</section> </section>
{isLoading ? ( {isLoading ? (
<div className="text-muted">Loading...</div> <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}> <form className="profile-form" onSubmit={submit}>
<div className="field"> <section className="profile-form__avatar-panel">
<Label htmlFor="slug">Slug (public URL)</Label> <span className="profile-form__avatar-preview">
<Input id="slug" {...register('slug')} /> {avatarPreview && !avatarFailed ? (
{errors.slug && <span className="field__error">{errors.slug.message}</span>} <img
src={avatarPreview}
alt=""
className="profile-form__avatar-image"
onError={() => setAvatarFailed(true)}
/>
) : (
<Gift className="h-6 w-6" />
)}
</span>
<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> </div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={(e) => {
handleAvatarFile(e.target.files?.[0]);
e.currentTarget.value = '';
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={uploadAvatar.isPending}
>
{uploadAvatar.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t('profile.uploadAvatar')}
</Button>
</section>
<div className="field"> <div className="field">
<Label htmlFor="displayName">Display name</Label> <Label htmlFor="slug">{t('profile.slug')}</Label>
<Input id="displayName" {...register('displayName')} /> <Input id="slug" {...register('slug')} />
{errors.displayName && ( {errors.slug && (
<span className="field__error">{errors.displayName.message}</span> <span className="field__error">{translateValidation(t, errors.slug.message)}</span>
)} )}
</div> </div>
<div className="field"> <div className="field">
<Label htmlFor="bio">Bio</Label> <Label htmlFor="displayName">{t('profile.displayName')}</Label>
<Input id="displayName" {...register('displayName')} />
{errors.displayName && (
<span className="field__error">
{translateValidation(t, errors.displayName.message)}
</span>
)}
</div>
<div className="field">
<Label htmlFor="bio">{t('profile.bio')}</Label>
<Textarea id="bio" rows={3} {...register('bio')} /> <Textarea id="bio" rows={3} {...register('bio')} />
</div> </div>
<div className="field"> <div className="field">
<Label htmlFor="avatarUrl">Avatar URL</Label> <Label htmlFor="avatarUrl">{t('profile.avatarUrl')}</Label>
<Input id="avatarUrl" type="url" {...register('avatarUrl')} /> <Input id="avatarUrl" inputMode="url" {...register('avatarUrl')} />
{errors.avatarUrl && (
<span className="field__error">
{translateValidation(t, errors.avatarUrl.message)}
</span>
)}
</div> </div>
<div className="flex items-center justify-end gap-2"> <div className="profile-form__actions">
<Button type="submit" disabled={!isDirty || isSubmitting}> <Button type="submit" disabled={!isDirty || isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />} {isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
Save changes {t('common.saveChanges')}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -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,
@@ -9,10 +9,17 @@ import { api } from '@/lib/api';
import { WishCard } from '@/components/WishCard/WishCard'; import { WishCard } from '@/components/WishCard/WishCard';
import { Footer } from '@/components/Layout/Footer'; import { Footer } from '@/components/Layout/Footer';
import { Gift } from 'lucide-react'; 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() { export function PublicProfilePage() {
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],
@@ -51,46 +58,65 @@ 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="flex min-h-screen flex-col"> <div className="public-profile">
<main className="container-page flex-1 py-10"> <div className="public-profile__toolbar">
{profile.isLoading && <div className="text-muted">Loading...</div>} {user ? (
<Link to="/">
<Button variant="secondary" size="sm">
{t('public.backToMine')}
</Button>
</Link>
) : (
<span />
)}
<LanguageSwitcher />
</div>
<main className="public-profile__main">
{profile.isLoading && <div className="text-muted">{t('common.loading')}</div>}
{profile.isError && ( {profile.isError && (
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface p-8 text-center shadow-card"> <div className="empty-state mx-auto max-w-lg bg-surface p-8">
<h1 className="font-display text-2xl">Profile not found</h1> <h1 className="empty-state__title">{t('public.notFoundTitle')}</h1>
<p className="mt-2 text-sm text-muted"> <p className="empty-state__text mt-2">
Check the link and try again. Slugs are case-sensitive. {t('public.notFoundText')}
</p> </p>
</div> </div>
)} )}
{profile.data && ( {profile.data && (
<> <>
<section className="mb-10 flex flex-col items-center gap-3 text-center"> <section className="public-profile__hero">
<span className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-card"> <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="h-14 w-14 rounded-full object-cover" className="public-profile__avatar-image"
onError={() => setAvatarFailed(true)}
/> />
) : ( ) : (
<Gift className="h-6 w-6" /> <Gift className="h-6 w-6" />
)} )}
</span> </span>
<h1 className="font-display text-4xl">{profile.data.displayName}'s wishlist</h1> <h1 className="public-profile__title">
{t('public.wishlistTitle', { name: profile.data.displayName })}
</h1>
{profile.data.bio && ( {profile.data.bio && (
<p className="max-w-xl text-muted">{profile.data.bio}</p> <p className="public-profile__bio">{profile.data.bio}</p>
)} )}
</section> </section>
{wishes.isLoading && <div className="text-muted">Loading wishes...</div>} {wishes.isLoading && <div className="text-muted">{t('public.loadingWishes')}</div>}
{wishes.data && wishes.data.length === 0 && ( {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"> <div className="empty-state mx-auto max-w-lg">
<h2 className="text-xl font-semibold">No wishes yet</h2> <h2 className="empty-state__title">{t('public.emptyTitle')}</h2>
<p className="mt-1 text-sm text-muted">Check back later!</p> <p className="empty-state__text mt-1">{t('public.emptyText')}</p>
</div> </div>
)} )}

View File

@@ -3,27 +3,31 @@ import { TRASH_RETENTION_DAYS } from '@family-wishlist/shared';
import { WishCard } from '@/components/WishCard/WishCard'; import { WishCard } from '@/components/WishCard/WishCard';
import { useRestoreWish, useWishes } from '@/features/wishes/wishes.hooks'; import { useRestoreWish, useWishes } from '@/features/wishes/wishes.hooks';
import { daysLeftUntil } from '@/lib/format'; import { daysLeftUntil } from '@/lib/format';
import { useI18n } from '@/i18n/i18n';
export function TrashPage() { export function TrashPage() {
const { dayCount, t } = useI18n();
const { data, isLoading } = useWishes('deleted'); const { data, isLoading } = useWishes('deleted');
const restore = useRestoreWish(); const restore = useRestoreWish();
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
<section> <section className="page-section">
<h1 className="font-display text-3xl">Trash</h1> <h1 className="page-section__title">{t('trash.title')}</h1>
<p className="text-sm text-muted"> <p className="page-section__text">
Deleted wishes are kept for {TRASH_RETENTION_DAYS} days, then permanently removed. {t('trash.description', { days: dayCount(TRASH_RETENTION_DAYS) })}
</p> </p>
</section> </section>
{isLoading && <div className="text-muted">Loading...</div>} {isLoading && <div className="text-muted">{t('common.loading')}</div>}
{!isLoading && data && data.length === 0 && ( {!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"> <div className="empty-state">
<Trash2 className="h-10 w-10 text-muted" /> <Trash2 className="empty-state__icon" />
<h2 className="text-xl font-semibold">Trash is empty</h2> <h2 className="empty-state__title">{t('trash.emptyTitle')}</h2>
<p className="text-sm text-muted">Deleted wishes will appear here for 30 days.</p> <p className="empty-state__text">
{t('trash.emptyText', { days: dayCount(TRASH_RETENTION_DAYS) })}
</p>
</div> </div>
)} )}
@@ -41,7 +45,7 @@ export function TrashPage() {
onRestore={() => restore.mutate(wish.id)} onRestore={() => restore.mutate(wish.id)}
footer={ footer={
<p className="mt-2 text-xs font-medium text-warning"> <p className="mt-2 text-xs font-medium text-warning">
Auto-removes in {left} day{left === 1 ? '' : 's'} {t('trash.autoRemove', { days: dayCount(left) })}
</p> </p>
} }
/> />

View File

@@ -1,4 +1,4 @@
import { createBrowserRouter } from 'react-router-dom'; import { createBrowserRouter, type RouterProviderProps } from 'react-router-dom';
import { ProtectedRoute } from './components/Layout/ProtectedRoute'; import { ProtectedRoute } from './components/Layout/ProtectedRoute';
import { AppShell } from './components/Layout/AppShell'; import { AppShell } from './components/Layout/AppShell';
import { LoginPage } from './pages/LoginPage'; import { LoginPage } from './pages/LoginPage';
@@ -10,7 +10,7 @@ import { ProfileSettingsPage } from './pages/ProfileSettingsPage';
import { PublicProfilePage } from './pages/PublicProfilePage'; import { PublicProfilePage } from './pages/PublicProfilePage';
import { NotFoundPage } from './pages/NotFoundPage'; import { NotFoundPage } from './pages/NotFoundPage';
export const router = createBrowserRouter([ export const router: RouterProviderProps['router'] = createBrowserRouter([
{ path: '/login', element: <LoginPage /> }, { path: '/login', element: <LoginPage /> },
{ path: '/u/:slug', element: <PublicProfilePage /> }, { path: '/u/:slug', element: <PublicProfilePage /> },
{ {

View File

@@ -29,6 +29,192 @@
@layer components { @layer components {
/* BEM-friendly classes: kept parallel to Tailwind utilities where possible. */ /* 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 { .wish-card {
@apply relative flex flex-col overflow-hidden rounded-lg bg-surface shadow-card transition-transform duration-200; @apply relative flex flex-col overflow-hidden rounded-lg bg-surface shadow-card transition-transform duration-200;
} }

16
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
postgres:
image: postgres:16-alpine
container_name: family-wishlist-postgres-dev
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
ports:
- "5432:5432"
volumes:
- pgdata_dev:/var/lib/postgresql/data
volumes:
pgdata_dev:

50
docker-compose.yml Normal file
View File

@@ -0,0 +1,50 @@
services:
family-wishlist-backend:
build:
context: .
dockerfile: docker/backend.Dockerfile
container_name: family-wishlist-backend
restart: unless-stopped
env_file: .env
environment:
NODE_ENV: production
UPLOADS_DIR: /app/apps/backend/uploads
BACKEND_PORT: 3000
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
volumes:
- uploads:/app/apps/backend/uploads
healthcheck:
test:
[
"CMD",
"wget",
"-qO-",
"http://127.0.0.1:3000/api/health",
]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
networks:
- postgres_default
frontend:
build:
context: .
dockerfile: docker/frontend.Dockerfile
container_name: family-wishlist-frontend
restart: unless-stopped
depends_on:
family-wishlist-backend:
condition: service_healthy
ports:
- "3055:80"
networks:
- postgres_default
volumes:
uploads:
networks:
postgres_default:
external: true

52
docker/backend.Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
RUN apk add --no-cache openssl
# ---------- deps ----------
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/
COPY apps/backend/package.json apps/backend/
COPY apps/frontend/package.json apps/frontend/
# Prisma schema is needed before `prisma generate` so we copy it at this stage.
COPY apps/backend/prisma apps/backend/prisma
# Install only the backend workspace and its deps.
RUN pnpm install --filter @family-wishlist/backend... --frozen-lockfile || \
pnpm install --filter @family-wishlist/backend...
# Generate @prisma/client into node_modules so it's present in the runtime stage.
RUN pnpm --filter @family-wishlist/backend prisma:generate
# ---------- build ----------
FROM deps AS build
WORKDIR /app
COPY packages/shared packages/shared
COPY apps/backend apps/backend
RUN pnpm --filter @family-wishlist/shared build
RUN pnpm --filter @family-wishlist/backend build
# ---------- runtime ----------
FROM base AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Copy everything needed for running: hoisted node_modules (with the generated
# @prisma/client inside), workspace packages, and the compiled backend.
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=build /app/packages ./packages
COPY --from=build /app/apps/backend/node_modules ./apps/backend/node_modules
COPY --from=build /app/apps/backend/dist ./apps/backend/dist
COPY --from=build /app/apps/backend/prisma ./apps/backend/prisma
COPY --from=build /app/apps/backend/package.json ./apps/backend/package.json
COPY --from=build /app/apps/backend/scripts ./apps/backend/scripts
WORKDIR /app/apps/backend
EXPOSE 3000
# Apply schema (idempotent; uses `db push` so no prior migrations required) +
# seed env users + start server.
CMD ["sh", "-c", "pnpm exec prisma db push --accept-data-loss --skip-generate && node dist/prisma/seed.js && node dist/src/index.js"]

View File

@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# ---------- deps ----------
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/
COPY apps/backend/package.json apps/backend/
COPY apps/frontend/package.json apps/frontend/
RUN pnpm install --filter @family-wishlist/frontend... --frozen-lockfile || \
pnpm install --filter @family-wishlist/frontend...
# ---------- build ----------
FROM deps AS build
WORKDIR /app
COPY packages/shared packages/shared
COPY apps/frontend apps/frontend
RUN pnpm --filter @family-wishlist/shared build
RUN pnpm --filter @family-wishlist/frontend build
# ---------- runtime (nginx) ----------
FROM nginx:1.27-alpine AS runtime
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/frontend/dist /usr/share/nginx/html
EXPOSE 80

38
docker/nginx.conf Normal file
View File

@@ -0,0 +1,38 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# API proxy
location /api/ {
proxy_pass http://family-wishlist-backend:3000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
client_max_body_size 10m;
}
# Uploaded files (images)
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;
}
}

View File

@@ -1,18 +1,21 @@
{ {
"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": "./src/index.ts", "main": "./dist/index.js",
"types": "./src/index.ts", "types": "./dist/index.d.ts",
"exports": { "exports": {
".": "./src/index.ts" ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "echo 'shared is source-only'", "build": "tsc -p tsconfig.json",
"lint": "echo 'skip'", "lint": "echo 'skip'",
"dev": "echo 'shared is source-only'" "dev": "tsc -p tsconfig.json --watch"
}, },
"dependencies": { "dependencies": {
"zod": "^3.23.8" "zod": "^3.23.8"

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()
@@ -24,7 +32,20 @@ export const updateProfileSchema = z.object({
.optional(), .optional(),
displayName: z.string().trim().min(1).max(64).optional(), displayName: z.string().trim().min(1).max(64).optional(),
bio: z.string().trim().max(500).nullable().optional(), bio: z.string().trim().max(500).nullable().optional(),
avatarUrl: z.string().url().nullable().optional(), avatarUrl: z
.preprocess(
(value) => (value === '' ? null : value),
z
.string()
.refine(
(value) => value.startsWith('/uploads/avatar/') || z.string().url().safeParse(value).success,
{
message: 'Avatar must be a URL or an uploaded avatar path',
},
)
.nullable(),
)
.optional(),
}); });
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>; export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;

View File

@@ -3,7 +3,7 @@
"compilerOptions": { "compilerOptions": {
"rootDir": "./src", "rootDir": "./src",
"outDir": "./dist", "outDir": "./dist",
"noEmit": true "noEmit": false
}, },
"include": ["src/**/*"] "include": ["src/**/*"]
} }

3647
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff