Compare commits
24 Commits
docs/readm
...
fix/friend
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14a57b19b7 | ||
| 17d59c3639 | |||
|
|
34179b3f30 | ||
| f8fcda0d13 | |||
|
|
1b23097b18 | ||
| db41d4a246 | |||
|
|
547a452097 | ||
| 3d7501f028 | |||
|
|
7c658706ea | ||
| 1c9c21d5a7 | |||
|
|
89f75e6d40 | ||
| e69f53114d | |||
|
|
793f0c3422 | ||
| d99002dc3c | |||
|
|
c49abafc61 | ||
| 4f4f9ff998 | |||
|
|
2adb03ff33 | ||
| a7d5260ce3 | |||
| 1a978ca98d | |||
| 1e228f7be6 | |||
| 1634a3ac27 | |||
|
|
d84b9b5ee7 | ||
|
|
00f01611ed | ||
|
|
2972090c48 |
14
.env.example
14
.env.example
@@ -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)
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -5,7 +5,7 @@ A small, private wishlist app for two users. Each user has their own profile, sl
|
|||||||
- **Backend**: Node.js 20, Fastify 4, Prisma 5, PostgreSQL 16
|
- **Backend**: Node.js 20, Fastify 4, Prisma 5, PostgreSQL 16
|
||||||
- **Frontend**: React 18, Vite 5, Tailwind CSS, TanStack Query, React Hook Form + Zod
|
- **Frontend**: React 18, Vite 5, Tailwind CSS, TanStack Query, React Hook Form + Zod
|
||||||
- **Monorepo**: pnpm workspaces
|
- **Monorepo**: pnpm workspaces
|
||||||
- **Deploy**: Docker Compose (Postgres + backend + nginx-served frontend)
|
- **Deploy**: Docker Compose (shared Postgres + backend + nginx-served frontend)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ apps/
|
|||||||
packages/
|
packages/
|
||||||
shared/ zod schemas + DTO types shared between backend and frontend
|
shared/ zod schemas + DTO types shared between backend and frontend
|
||||||
docker/ Dockerfiles + nginx.conf
|
docker/ Dockerfiles + nginx.conf
|
||||||
docker-compose.yml prod stack (postgres + backend + frontend)
|
docker-compose.yml prod stack (shared postgres + backend + frontend)
|
||||||
docker-compose.dev.yml dev helper (postgres only)
|
docker-compose.dev.yml dev helper (postgres only)
|
||||||
.env.example full env template
|
.env.example full env template
|
||||||
```
|
```
|
||||||
@@ -127,10 +127,11 @@ Review the rest of `.env`:
|
|||||||
|
|
||||||
- `USER1_USERNAME`, `USER1_SLUG`, `USER1_DISPLAY_NAME`
|
- `USER1_USERNAME`, `USER1_SLUG`, `USER1_DISPLAY_NAME`
|
||||||
- `USER2_USERNAME`, `USER2_SLUG`, `USER2_DISPLAY_NAME`
|
- `USER2_USERNAME`, `USER2_SLUG`, `USER2_DISPLAY_NAME`
|
||||||
- `POSTGRES_*` (used by Docker)
|
- `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)
|
- `PUBLIC_APP_URL` (used for CORS in production)
|
||||||
|
|
||||||
### 3. Run everything via Docker
|
### 3. Run the shared Docker stack
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
@@ -139,12 +140,18 @@ docker compose up --build
|
|||||||
Opens:
|
Opens:
|
||||||
|
|
||||||
- Frontend: http://localhost:8080
|
- Frontend: http://localhost:8080
|
||||||
- Backend API: http://localhost:8080/api (proxied by nginx) or http://localhost:3000 if you map `backend`
|
- Backend API: http://localhost:8080/api (proxied by nginx) or http://localhost:3000 if you map `family-wishlist-backend`
|
||||||
- Postgres: internal only
|
|
||||||
|
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:
|
On first start, the backend:
|
||||||
|
|
||||||
1. Runs `prisma db push` against the Postgres service (creates tables from `schema.prisma`; idempotent).
|
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).
|
2. Seeds/upserts both users from env (public fields only — password hash stays in env).
|
||||||
3. Starts Fastify on port 3000.
|
3. Starts Fastify on port 3000.
|
||||||
4. Registers the daily trash-purge cron (runs at 03:17 UTC, also once on startup).
|
4. Registers the daily trash-purge cron (runs at 03:17 UTC, also once on startup).
|
||||||
@@ -159,7 +166,7 @@ Run Postgres in a container, apps on the host:
|
|||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
# Override DATABASE_URL to point to localhost:
|
# Override DATABASE_URL to point to localhost:
|
||||||
# DATABASE_URL=postgresql://wishlist:change_me@localhost:5432/family_wishlist
|
# 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 prisma:push # apply schema (first time and on schema changes)
|
||||||
pnpm --filter @family-wishlist/backend seed # upsert two users from env into DB
|
pnpm --filter @family-wishlist/backend seed # upsert two users from env into DB
|
||||||
@@ -170,6 +177,9 @@ This starts both apps in parallel:
|
|||||||
|
|
||||||
- Frontend: http://localhost:5173 (proxying `/api` and `/uploads` to http://localhost:3000)
|
- Frontend: http://localhost:5173 (proxying `/api` and `/uploads` to http://localhost:3000)
|
||||||
- Backend: 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
|
### 5. Useful scripts
|
||||||
|
|
||||||
|
|||||||
6
apps/backend/.dockerignore
Normal file
6
apps/backend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
uploads
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
51
apps/backend/package.json
Normal file
51
apps/backend/package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "@family-wishlist/backend",
|
||||||
|
"version": "0.1.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
81
apps/backend/prisma/schema.prisma
Normal file
81
apps/backend/prisma/schema.prisma
Normal 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])
|
||||||
|
}
|
||||||
37
apps/backend/prisma/seed.ts
Normal file
37
apps/backend/prisma/seed.ts
Normal 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);
|
||||||
|
});
|
||||||
27
apps/backend/scripts/hash-password.ts
Normal file
27
apps/backend/scripts/hash-password.ts
Normal 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
86
apps/backend/src/app.ts
Normal 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;
|
||||||
|
}
|
||||||
30
apps/backend/src/auth/users.registry.ts
Normal file
30
apps/backend/src/auth/users.registry.ts
Normal 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;
|
||||||
|
}
|
||||||
7
apps/backend/src/auth/users.registry.types.ts
Normal file
7
apps/backend/src/auth/users.registry.types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface RegistryUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
passwordHash: string;
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
89
apps/backend/src/config/env.ts
Normal file
89
apps/backend/src/config/env.ts
Normal 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
32
apps/backend/src/index.ts
Normal 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();
|
||||||
48
apps/backend/src/jobs/purge-trash.ts
Normal file
48
apps/backend/src/jobs/purge-trash.ts
Normal 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);
|
||||||
|
}
|
||||||
43
apps/backend/src/modules/auth/auth.routes.ts
Normal file
43
apps/backend/src/modules/auth/auth.routes.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/backend/src/modules/auth/auth.service.ts
Normal file
25
apps/backend/src/modules/auth/auth.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
50
apps/backend/src/modules/images/images.routes.ts
Normal file
50
apps/backend/src/modules/images/images.routes.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
99
apps/backend/src/modules/images/og.service.ts
Normal file
99
apps/backend/src/modules/images/og.service.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
52
apps/backend/src/modules/images/storage.service.ts
Normal file
52
apps/backend/src/modules/images/storage.service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/backend/src/modules/meta/meta.routes.ts
Normal file
7
apps/backend/src/modules/meta/meta.routes.ts
Normal 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() }));
|
||||||
|
}
|
||||||
65
apps/backend/src/modules/profile/profile.routes.ts
Normal file
65
apps/backend/src/modules/profile/profile.routes.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
58
apps/backend/src/modules/public/public.routes.ts
Normal file
58
apps/backend/src/modules/public/public.routes.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
75
apps/backend/src/modules/wishes/wishes.routes.ts
Normal file
75
apps/backend/src/modules/wishes/wishes.routes.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
117
apps/backend/src/modules/wishes/wishes.service.ts
Normal file
117
apps/backend/src/modules/wishes/wishes.service.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/backend/src/plugins/auth.ts
Normal file
52
apps/backend/src/plugins/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/backend/src/plugins/cors.ts
Normal file
10
apps/backend/src/plugins/cors.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
25
apps/backend/src/plugins/guest.ts
Normal file
25
apps/backend/src/plugins/guest.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
13
apps/backend/src/plugins/multipart.ts
Normal file
13
apps/backend/src/plugins/multipart.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
18
apps/backend/src/plugins/prisma.ts
Normal file
18
apps/backend/src/plugins/prisma.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
8
apps/backend/src/plugins/rate-limit.ts
Normal file
8
apps/backend/src/plugins/rate-limit.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
20
apps/backend/src/plugins/static.ts
Normal file
20
apps/backend/src/plugins/static.ts
Normal 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
22
apps/backend/src/types/fastify.d.ts
vendored
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/backend/src/utils/errors.ts
Normal file
49
apps/backend/src/utils/errors.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/backend/src/utils/password.ts
Normal file
11
apps/backend/src/utils/password.ts
Normal 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);
|
||||||
|
}
|
||||||
31
apps/backend/src/utils/version.ts
Normal file
31
apps/backend/src/utils/version.ts
Normal 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;
|
||||||
|
}
|
||||||
10
apps/backend/tsconfig.build.json
Normal file
10
apps/backend/tsconfig.build.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"rootDir": "./",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "prisma/seed.ts"],
|
||||||
|
"exclude": ["**/*.test.ts", "dist"]
|
||||||
|
}
|
||||||
16
apps/backend/tsconfig.json
Normal file
16
apps/backend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
5
apps/frontend/.dockerignore
Normal file
5
apps/frontend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
20
apps/frontend/index.html
Normal file
20
apps/frontend/index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<link rel="preconnect" href="https://rsms.me/" />
|
||||||
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>Family Wishlist</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
apps/frontend/package.json
Normal file
39
apps/frontend/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@family-wishlist/frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 4173",
|
||||||
|
"typecheck": "tsc -b",
|
||||||
|
"lint": "echo 'skip'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@family-wishlist/shared": "workspace:*",
|
||||||
|
"@hookform/resolvers": "^3.9.0",
|
||||||
|
"@tanstack/react-query": "^5.56.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.445.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.53.0",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
|
"sonner": "^1.5.0",
|
||||||
|
"tailwind-merge": "^2.5.2",
|
||||||
|
"zod": "^3.23.8",
|
||||||
|
"zustand": "^4.5.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.16.5",
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.11",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/frontend/postcss.config.js
Normal file
6
apps/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
37
apps/frontend/public/default-gift.svg
Normal file
37
apps/frontend/public/default-gift.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 240" width="320" height="240" role="img" aria-label="Gift box">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFF2EA" />
|
||||||
|
<stop offset="1" stop-color="#FFE2D1" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="box" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#E27896" />
|
||||||
|
<stop offset="1" stop-color="#C85276" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="ribbon" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F4C04E" />
|
||||||
|
<stop offset="1" stop-color="#E0A21A" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="320" height="240" fill="url(#bg)" />
|
||||||
|
<g transform="translate(70 60)">
|
||||||
|
<!-- box body -->
|
||||||
|
<rect x="0" y="40" width="180" height="110" rx="10" fill="url(#box)" />
|
||||||
|
<rect x="0" y="36" width="180" height="16" rx="8" fill="#F2A1B6" />
|
||||||
|
<!-- vertical ribbon -->
|
||||||
|
<rect x="80" y="36" width="20" height="114" fill="url(#ribbon)" />
|
||||||
|
<!-- bow -->
|
||||||
|
<g transform="translate(90 22)">
|
||||||
|
<path d="M0 0 C-28 -24 -46 12 -20 14 L0 14 Z" fill="url(#ribbon)" />
|
||||||
|
<path d="M0 0 C28 -24 46 12 20 14 L0 14 Z" fill="url(#ribbon)" />
|
||||||
|
<circle cx="0" cy="10" r="6" fill="#F4C04E" stroke="#B7830D" stroke-width="1" />
|
||||||
|
</g>
|
||||||
|
<!-- sparkles -->
|
||||||
|
<g fill="#F4C04E" opacity="0.9">
|
||||||
|
<circle cx="-28" cy="18" r="3" />
|
||||||
|
<circle cx="210" cy="48" r="2.5" />
|
||||||
|
<circle cx="-18" cy="120" r="2" />
|
||||||
|
<circle cx="206" cy="130" r="3" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
28
apps/frontend/public/empty-state.svg
Normal file
28
apps/frontend/public/empty-state.svg
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 240" width="260" height="240" role="img" aria-label="Empty wishlist">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="e-bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFF2EA" stop-opacity="0" />
|
||||||
|
<stop offset="1" stop-color="#FFE2D1" stop-opacity="0.9" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="20" y="30" width="220" height="190" rx="20" fill="url(#e-bg)" stroke="#EBD7C6" stroke-dasharray="4 6" />
|
||||||
|
<g transform="translate(130 130)">
|
||||||
|
<circle r="54" fill="#FFE2D1" />
|
||||||
|
<circle r="54" fill="none" stroke="#F4C04E" stroke-opacity="0.5" stroke-width="2" />
|
||||||
|
<g transform="translate(-32 -30)">
|
||||||
|
<rect x="0" y="14" width="64" height="48" rx="6" fill="#E27896" />
|
||||||
|
<rect x="0" y="10" width="64" height="10" rx="4" fill="#F2A1B6" />
|
||||||
|
<rect x="28" y="10" width="8" height="52" fill="#F4C04E" />
|
||||||
|
<g transform="translate(32 4)">
|
||||||
|
<path d="M0 0 C-14 -12 -24 6 -10 8 L0 8 Z" fill="#F4C04E" />
|
||||||
|
<path d="M0 0 C14 -12 24 6 10 8 L0 8 Z" fill="#F4C04E" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g fill="#F4C04E" opacity="0.85">
|
||||||
|
<circle cx="40" cy="60" r="3" />
|
||||||
|
<circle cx="222" cy="82" r="2.5" />
|
||||||
|
<circle cx="52" cy="200" r="2" />
|
||||||
|
<circle cx="214" cy="186" r="3" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
12
apps/frontend/public/favicon.svg
Normal file
12
apps/frontend/public/favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#E27896" />
|
||||||
|
<g transform="translate(14 18)">
|
||||||
|
<rect x="0" y="12" width="36" height="22" rx="3" fill="#FFF1F6" />
|
||||||
|
<rect x="0" y="9" width="36" height="6" rx="2" fill="#F2A1B6" />
|
||||||
|
<rect x="15" y="9" width="6" height="25" fill="#F4C04E" />
|
||||||
|
<g transform="translate(18 5)">
|
||||||
|
<path d="M0 0 C-8 -7 -14 3 -6 5 L0 5 Z" fill="#F4C04E" />
|
||||||
|
<path d="M0 0 C8 -7 14 3 6 5 L0 5 Z" fill="#F4C04E" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 563 B |
37
apps/frontend/src/App.tsx
Normal file
37
apps/frontend/src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
import { router } from './routes';
|
||||||
|
import { useAuthStore } from './features/auth/authStore';
|
||||||
|
import { I18nProvider } from './i18n/i18n';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function AuthBoot({ children }: { children: React.ReactNode }) {
|
||||||
|
const init = useAuthStore((s) => s.init);
|
||||||
|
useEffect(() => {
|
||||||
|
void init();
|
||||||
|
}, [init]);
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthBoot>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AuthBoot>
|
||||||
|
<Toaster position="top-center" richColors closeButton />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/frontend/src/components/LanguageSwitcher.tsx
Normal file
41
apps/frontend/src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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(
|
||||||
|
'inline-flex items-center gap-1 rounded-md border border-border bg-surface/90 p-1 text-xs shadow-card',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={t('language.switch')}
|
||||||
|
>
|
||||||
|
<Languages className="ml-1 h-3.5 w-3.5 text-muted" aria-hidden />
|
||||||
|
{languages.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLanguage(item.value)}
|
||||||
|
className={cn(
|
||||||
|
'rounded px-2 py-1 font-semibold transition-colors',
|
||||||
|
language === item.value
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted hover:bg-surface-muted hover:text-ink',
|
||||||
|
)}
|
||||||
|
aria-pressed={language === item.value}
|
||||||
|
title={item.value === 'ru' ? t('language.ru') : t('language.en')}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/frontend/src/components/Layout/AppShell.tsx
Normal file
15
apps/frontend/src/components/Layout/AppShell.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Header } from './Header';
|
||||||
|
import { Footer } from './Footer';
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="container-page flex-1 py-6 sm:py-10">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/frontend/src/components/Layout/Footer.tsx
Normal file
34
apps/frontend/src/components/Layout/Footer.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Gift } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { FRONTEND_VERSION } from '@/lib/version';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
interface VersionInfo {
|
||||||
|
backend: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['version'],
|
||||||
|
queryFn: () => api.get<VersionInfo>('/api/version'),
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="container-page mt-10 py-6 text-xs text-muted">
|
||||||
|
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gift className="h-4 w-4" aria-hidden />
|
||||||
|
<span className="font-display text-sm">{t('app.name')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>{t('footer.frontend', { version: FRONTEND_VERSION })}</span>
|
||||||
|
<span className="opacity-50">·</span>
|
||||||
|
<span>{t('footer.backend', { version: data?.backend ?? '...' })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
apps/frontend/src/components/Layout/Header.tsx
Normal file
119
apps/frontend/src/components/Layout/Header.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { Archive, CheckCircle2, Gift, LogOut, Sparkles, Trash2, UserCog, Users } from 'lucide-react';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
import { useAuthStore } from '@/features/auth/authStore';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { useI18n, type TranslationKey } from '@/i18n/i18n';
|
||||||
|
import { LanguageSwitcher } from '../LanguageSwitcher';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
type NavIcon = ComponentType<{ className?: string }>;
|
||||||
|
|
||||||
|
interface FriendProfile {
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ to: '/', label: 'header.active', icon: Sparkles, end: true },
|
||||||
|
{ to: '/completed', label: 'header.fulfilled', icon: CheckCircle2 },
|
||||||
|
{ to: '/archive', label: 'header.archive', icon: Archive },
|
||||||
|
{ to: '/trash', label: 'header.trash', icon: Trash2 },
|
||||||
|
] satisfies Array<{
|
||||||
|
to: string;
|
||||||
|
label: TranslationKey;
|
||||||
|
icon: NavIcon;
|
||||||
|
end?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const friend = useQuery({
|
||||||
|
queryKey: ['profile-friend'],
|
||||||
|
queryFn: () => api.get<FriendProfile | null>('/api/profile/friend'),
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
enabled: user != null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="container-page pt-6">
|
||||||
|
<div className="grid grid-cols-1 items-center gap-3 rounded-lg border border-border bg-surface/80 px-4 py-3 backdrop-blur lg:grid-cols-[auto_minmax(0,1fr)_auto]">
|
||||||
|
<Link to="/" className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
|
||||||
|
<Gift className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-display text-lg leading-tight">{t('app.name')}</div>
|
||||||
|
<div className="truncate text-xs text-muted">
|
||||||
|
{t('header.signedInAs', { name: user.displayName })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap lg:justify-center">
|
||||||
|
{links.map((l) => (
|
||||||
|
<NavLink
|
||||||
|
key={l.to}
|
||||||
|
to={l.to}
|
||||||
|
end={l.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
isActive ? 'bg-primary text-primary-foreground shadow-card' : 'text-ink hover:bg-ink/5',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<l.icon className="h-4 w-4" />
|
||||||
|
{t(l.label)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
{friend.data && (
|
||||||
|
<NavLink
|
||||||
|
to={`/u/${friend.data.slug}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
isActive ? 'bg-primary text-primary-foreground shadow-card' : 'text-ink hover:bg-ink/5',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
{t('header.friendWishes', { name: friend.data.displayName })}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1 justify-self-start lg:justify-self-end">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
title={t('header.profileSettings')}
|
||||||
|
>
|
||||||
|
<UserCog className="h-4 w-4" />
|
||||||
|
{t('header.profile')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void logout().then(() => navigate('/login'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
{t('header.logout')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/frontend/src/components/Layout/ProtectedRoute.tsx
Normal file
21
apps/frontend/src/components/Layout/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useAuthStore } from '@/features/auth/authStore';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||||
|
const { user, status } = useAuthStore();
|
||||||
|
const location = useLocation();
|
||||||
|
const { t } = useI18n();
|
||||||
|
if (status !== 'ready') {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[50vh] items-center justify-center text-muted">
|
||||||
|
{t('protected.loading')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
53
apps/frontend/src/components/WishBadges/WishBadges.tsx
Normal file
53
apps/frontend/src/components/WishBadges/WishBadges.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Check, Sparkles, Archive, Trash2 } from 'lucide-react';
|
||||||
|
import type { Wish } from '@family-wishlist/shared';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
wish: Wish & { isNewForOwner?: boolean };
|
||||||
|
view: 'owner' | 'guest';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WishBadges({ wish, view, className }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const badges: JSX.Element[] = [];
|
||||||
|
|
||||||
|
const isNew =
|
||||||
|
view === 'owner' ? wish.isNewForOwner === true : wish.isNewForGuest === true;
|
||||||
|
if (isNew && wish.status === 'ACTIVE') {
|
||||||
|
badges.push(
|
||||||
|
<span className="wish-badge wish-badge--new" key="new">
|
||||||
|
<Sparkles className="h-3 w-3" aria-hidden />
|
||||||
|
{t('wish.badge.new')}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (wish.status === 'COMPLETED') {
|
||||||
|
badges.push(
|
||||||
|
<span className="wish-badge wish-badge--completed" key="done">
|
||||||
|
<Check className="h-3 w-3" aria-hidden />
|
||||||
|
{t('wish.badge.fulfilled')}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (wish.status === 'ARCHIVED' && view === 'owner') {
|
||||||
|
badges.push(
|
||||||
|
<span className="wish-badge wish-badge--archived" key="arch">
|
||||||
|
<Archive className="h-3 w-3" aria-hidden />
|
||||||
|
{t('wish.badge.archived')}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (wish.status === 'DELETED' && view === 'owner') {
|
||||||
|
badges.push(
|
||||||
|
<span className="wish-badge wish-badge--deleted" key="del">
|
||||||
|
<Trash2 className="h-3 w-3" aria-hidden />
|
||||||
|
{t('wish.badge.trash')}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badges.length === 0) return null;
|
||||||
|
return <div className={cn('flex flex-wrap gap-1.5', className)}>{badges}</div>;
|
||||||
|
}
|
||||||
193
apps/frontend/src/components/WishCard/WishCard.tsx
Normal file
193
apps/frontend/src/components/WishCard/WishCard.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { memo, useState } from 'react';
|
||||||
|
import type { Wish } from '@family-wishlist/shared';
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
CheckCircle2,
|
||||||
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { WishBadges } from '../WishBadges/WishBadges';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { formatPrice } from '@/lib/format';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
export type WishCardView = 'owner' | 'guest';
|
||||||
|
|
||||||
|
export interface WishCardAction {
|
||||||
|
key: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WishCardProps {
|
||||||
|
wish: Wish & { isNewForOwner?: boolean };
|
||||||
|
view: WishCardView;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onArchive?: () => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
onRestore?: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WishCardInner({
|
||||||
|
wish,
|
||||||
|
view,
|
||||||
|
onEdit,
|
||||||
|
onArchive,
|
||||||
|
onComplete,
|
||||||
|
onRestore,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
|
footer,
|
||||||
|
}: WishCardProps) {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
const completed = wish.status === 'COMPLETED';
|
||||||
|
const priceLabel = formatPrice(wish.price, wish.currency, locale);
|
||||||
|
const imageSrc = wish.imageUrl ?? '/default-gift.svg';
|
||||||
|
|
||||||
|
const actions: WishCardAction[] = [];
|
||||||
|
if (view === 'owner') {
|
||||||
|
if (wish.status === 'ACTIVE') {
|
||||||
|
if (onEdit) actions.push({ key: 'edit', icon: Pencil, label: t('wish.action.edit'), onClick: onEdit });
|
||||||
|
if (onComplete)
|
||||||
|
actions.push({
|
||||||
|
key: 'complete',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
label: t('wish.action.complete'),
|
||||||
|
onClick: onComplete,
|
||||||
|
});
|
||||||
|
if (onArchive)
|
||||||
|
actions.push({ key: 'archive', icon: Archive, label: t('wish.action.archive'), onClick: onArchive });
|
||||||
|
if (onDelete)
|
||||||
|
actions.push({
|
||||||
|
key: 'delete',
|
||||||
|
icon: Trash2,
|
||||||
|
label: t('wish.action.delete'),
|
||||||
|
onClick: onDelete,
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
} else if (wish.status === 'ARCHIVED') {
|
||||||
|
if (onRestore)
|
||||||
|
actions.push({ key: 'restore', icon: RotateCcw, label: t('wish.action.restore'), onClick: onRestore });
|
||||||
|
if (onDelete)
|
||||||
|
actions.push({
|
||||||
|
key: 'delete',
|
||||||
|
icon: Trash2,
|
||||||
|
label: t('wish.action.delete'),
|
||||||
|
onClick: onDelete,
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
} else if (wish.status === 'COMPLETED') {
|
||||||
|
if (onDuplicate)
|
||||||
|
actions.push({
|
||||||
|
key: 'duplicate',
|
||||||
|
icon: Copy,
|
||||||
|
label: t('wish.action.duplicate'),
|
||||||
|
onClick: onDuplicate,
|
||||||
|
});
|
||||||
|
if (onDelete)
|
||||||
|
actions.push({
|
||||||
|
key: 'delete',
|
||||||
|
icon: Trash2,
|
||||||
|
label: t('wish.action.delete'),
|
||||||
|
onClick: onDelete,
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
} else if (wish.status === 'DELETED') {
|
||||||
|
if (onRestore)
|
||||||
|
actions.push({ key: 'restore', icon: RotateCcw, label: t('wish.action.restore'), onClick: onRestore });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={cn('wish-card', completed && 'wish-card--completed')}>
|
||||||
|
<div className="wish-card__image-wrap">
|
||||||
|
<img
|
||||||
|
className="wish-card__image"
|
||||||
|
src={imageSrc}
|
||||||
|
alt={wish.title}
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).src = '/default-gift.svg';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-3">
|
||||||
|
<WishBadges wish={wish} view={view} />
|
||||||
|
</div>
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<div className="absolute right-3 top-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t('wish.actions')}
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 z-20 mt-2 w-48 overflow-hidden rounded-md border border-border bg-surface shadow-pop">
|
||||||
|
{actions.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
a.onClick();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-muted',
|
||||||
|
a.danger && 'text-danger',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<a.icon className="h-4 w-4" />
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="wish-card__body">
|
||||||
|
<h3 className="wish-card__title">{wish.title}</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{priceLabel && <span className="wish-card__price">{priceLabel}</span>}
|
||||||
|
{wish.url && (
|
||||||
|
<a
|
||||||
|
href={wish.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary-600"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
{t('wish.openLink')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{wish.comment && <p className="wish-card__comment">{wish.comment}</p>}
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WishCard = memo(WishCardInner);
|
||||||
214
apps/frontend/src/components/WishForm/WishForm.tsx
Normal file
214
apps/frontend/src/components/WishForm/WishForm.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { createWishSchema, type CreateWishInput, type Wish } from '@family-wishlist/shared';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import { Textarea } from '../ui/Textarea';
|
||||||
|
import { Label } from '../ui/Label';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { ImageIcon, Loader2, RefreshCcw, Trash2, Upload } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useCreateWish,
|
||||||
|
useRefreshOg,
|
||||||
|
useResetWishImage,
|
||||||
|
useUpdateWish,
|
||||||
|
useUploadWishImage,
|
||||||
|
} from '@/features/wishes/wishes.hooks';
|
||||||
|
import { translateValidation, useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
initial?: Wish;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WishForm({ open, mode, initial, onClose }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const create = useCreateWish();
|
||||||
|
const update = useUpdateWish();
|
||||||
|
const upload = useUploadWishImage();
|
||||||
|
const refreshOg = useRefreshOg();
|
||||||
|
const resetImage = useResetWishImage();
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<CreateWishInput>({
|
||||||
|
resolver: zodResolver(createWishSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: initial?.title ?? '',
|
||||||
|
price: initial?.price ?? '',
|
||||||
|
currency: initial?.currency ?? 'RUB',
|
||||||
|
url: initial?.url ?? '',
|
||||||
|
comment: initial?.comment ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
reset({
|
||||||
|
title: initial?.title ?? '',
|
||||||
|
price: initial?.price ?? '',
|
||||||
|
currency: initial?.currency ?? 'RUB',
|
||||||
|
url: initial?.url ?? '',
|
||||||
|
comment: initial?.comment ?? '',
|
||||||
|
});
|
||||||
|
setPendingId(initial?.id ?? null);
|
||||||
|
}, [open, initial, reset]);
|
||||||
|
|
||||||
|
const submit = handleSubmit(async (values) => {
|
||||||
|
if (mode === 'create') {
|
||||||
|
const created = await create.mutateAsync(values);
|
||||||
|
setPendingId(created.id);
|
||||||
|
} else if (initial) {
|
||||||
|
await update.mutateAsync({ id: initial.id, input: values });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeId = pendingId ?? initial?.id ?? null;
|
||||||
|
const canEditImage = activeId != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={mode === 'create' ? t('wishForm.addTitle') : t('wishForm.editTitle')}
|
||||||
|
description={
|
||||||
|
mode === 'create'
|
||||||
|
? t('wishForm.addDescription')
|
||||||
|
: t('wishForm.editDescription')
|
||||||
|
}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="wish-form" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{mode === 'create' ? t('wishForm.addSubmit') : t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="wish-form" className="grid gap-4" onSubmit={submit}>
|
||||||
|
<div className="field">
|
||||||
|
<Label htmlFor="title">{t('wishForm.title')}</Label>
|
||||||
|
<Input id="title" placeholder={t('wishForm.titlePlaceholder')} {...register('title')} />
|
||||||
|
{errors.title && (
|
||||||
|
<span className="field__error">{translateValidation(t, errors.title.message)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_auto]">
|
||||||
|
<div className="field">
|
||||||
|
<Label htmlFor="price">{t('wishForm.price')}</Label>
|
||||||
|
<Input
|
||||||
|
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 className="field">
|
||||||
|
<Label htmlFor="currency">{t('wishForm.currency')}</Label>
|
||||||
|
<Input id="currency" maxLength={3} className="uppercase w-24" {...register('currency')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<Label htmlFor="url">{t('wishForm.link')}</Label>
|
||||||
|
<Input id="url" type="url" placeholder="https://..." {...register('url')} />
|
||||||
|
{errors.url && (
|
||||||
|
<span className="field__error">
|
||||||
|
{translateValidation(t, errors.url.message as string)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{t('wishForm.linkHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<Label htmlFor="comment">{t('wishForm.comment')}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="comment"
|
||||||
|
rows={3}
|
||||||
|
placeholder={t('wishForm.commentPlaceholder')}
|
||||||
|
{...register('comment')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{canEditImage && activeId && (
|
||||||
|
<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">
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
{t('wishForm.image')}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) void upload.mutateAsync({ id: activeId, file });
|
||||||
|
e.currentTarget.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={upload.isPending}
|
||||||
|
>
|
||||||
|
{upload.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t('wishForm.uploadCustom')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void refreshOg.mutateAsync(activeId)}
|
||||||
|
disabled={refreshOg.isPending}
|
||||||
|
>
|
||||||
|
{refreshOg.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t('wishForm.refreshFromLink')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void resetImage.mutateAsync(activeId)}
|
||||||
|
disabled={resetImage.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{t('wishForm.resetImage')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/frontend/src/components/ui/Button.tsx
Normal file
42
apps/frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline';
|
||||||
|
type Size = 'sm' | 'md' | 'lg' | 'icon';
|
||||||
|
|
||||||
|
const base =
|
||||||
|
'inline-flex items-center justify-center gap-2 rounded-md font-medium transition-all duration-150 focus:outline-none focus-visible:shadow-focus disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
|
const variants: Record<Variant, string> = {
|
||||||
|
primary: 'bg-primary text-primary-foreground hover:bg-primary-600 shadow-card',
|
||||||
|
secondary: 'bg-surface text-ink shadow-card hover:bg-surface-muted',
|
||||||
|
ghost: 'text-ink hover:bg-ink/5',
|
||||||
|
outline: 'border border-border bg-surface text-ink hover:bg-surface-muted',
|
||||||
|
danger: 'bg-danger text-white hover:brightness-95 shadow-card',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes: Record<Size, string> = {
|
||||||
|
sm: 'h-8 px-3 text-sm',
|
||||||
|
md: 'h-10 px-4 text-sm',
|
||||||
|
lg: 'h-12 px-6 text-base',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||||
|
{ className, variant = 'primary', size = 'md', type = 'button', ...rest },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={cn(base, variants[variant], sizes[size], className)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
8
apps/frontend/src/components/ui/Input.tsx
Normal file
8
apps/frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
function Input({ className, ...rest }, ref) {
|
||||||
|
return <input ref={ref} className={cn('field__input', className)} {...rest} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
6
apps/frontend/src/components/ui/Label.tsx
Normal file
6
apps/frontend/src/components/ui/Label.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { LabelHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
export function Label({ className, ...rest }: LabelHTMLAttributes<HTMLLabelElement>) {
|
||||||
|
return <label className={cn('field__label', className)} {...rest} />;
|
||||||
|
}
|
||||||
78
apps/frontend/src/components/ui/Modal.tsx
Normal file
78
apps/frontend/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
size?: 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
size = 'md',
|
||||||
|
}: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-6">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-ink/40 backdrop-blur-sm animate-fade-in-up"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className={cn(
|
||||||
|
'relative w-full bg-surface shadow-pop animate-fade-in-up',
|
||||||
|
'rounded-t-xl sm:rounded-xl',
|
||||||
|
size === 'md' ? 'sm:max-w-lg' : 'sm:max-w-2xl',
|
||||||
|
'max-h-[90vh] overflow-hidden flex flex-col',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-lg font-semibold text-ink">{title}</h2>
|
||||||
|
{description && <p className="mt-1 text-sm text-muted">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div className="overflow-y-auto px-5 py-5">{children}</div>
|
||||||
|
{footer && (
|
||||||
|
<footer className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
|
||||||
|
{footer}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/frontend/src/components/ui/Textarea.tsx
Normal file
16
apps/frontend/src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { forwardRef, type TextareaHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
export const Textarea = forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
>(function Textarea({ className, rows = 3, ...rest }, ref) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
rows={rows}
|
||||||
|
className={cn('field__textarea', className)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
49
apps/frontend/src/features/auth/authStore.ts
Normal file
49
apps/frontend/src/features/auth/authStore.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { AuthUser } from '@family-wishlist/shared';
|
||||||
|
import { api, ApiError } from '@/lib/api';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: AuthUser | null;
|
||||||
|
status: 'idle' | 'loading' | 'ready';
|
||||||
|
init: () => Promise<void>;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
status: 'idle',
|
||||||
|
init: async () => {
|
||||||
|
set({ status: 'loading' });
|
||||||
|
try {
|
||||||
|
const user = await api.get<AuthUser>('/api/auth/me');
|
||||||
|
set({ user, status: 'ready' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
set({ user: null, status: 'ready' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({ user: null, status: 'ready' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
login: async (username, password) => {
|
||||||
|
const user = await api.post<AuthUser>('/api/auth/login', { username, password });
|
||||||
|
set({ user });
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/api/auth/logout');
|
||||||
|
} finally {
|
||||||
|
set({ user: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refresh: async () => {
|
||||||
|
try {
|
||||||
|
const user = await api.get<AuthUser>('/api/auth/me');
|
||||||
|
set({ user });
|
||||||
|
} catch {
|
||||||
|
set({ user: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
41
apps/frontend/src/features/wishes/wishes.api.ts
Normal file
41
apps/frontend/src/features/wishes/wishes.api.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type {
|
||||||
|
CreateWishInput,
|
||||||
|
UpdateWishInput,
|
||||||
|
Wish,
|
||||||
|
WishStatus,
|
||||||
|
} from '@family-wishlist/shared';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export type WishWithOwnerBadge = Wish & { isNewForOwner?: boolean };
|
||||||
|
|
||||||
|
export type OwnerStatus = 'active' | 'archived' | 'completed' | 'deleted';
|
||||||
|
|
||||||
|
export const wishesApi = {
|
||||||
|
list: (status: OwnerStatus) =>
|
||||||
|
api.get<WishWithOwnerBadge[]>(`/api/wishes?status=${status}`),
|
||||||
|
|
||||||
|
get: (id: string) => api.get<Wish>(`/api/wishes/${id}`),
|
||||||
|
|
||||||
|
create: (input: CreateWishInput) => api.post<Wish, CreateWishInput>('/api/wishes', input),
|
||||||
|
|
||||||
|
update: (id: string, input: UpdateWishInput) =>
|
||||||
|
api.patch<Wish, UpdateWishInput>(`/api/wishes/${id}`, input),
|
||||||
|
|
||||||
|
remove: (id: string) => api.delete<Wish>(`/api/wishes/${id}`),
|
||||||
|
archive: (id: string) => api.post<Wish>(`/api/wishes/${id}/archive`),
|
||||||
|
complete: (id: string) => api.post<Wish>(`/api/wishes/${id}/complete`),
|
||||||
|
restore: (id: string) => api.post<Wish>(`/api/wishes/${id}/restore`),
|
||||||
|
duplicate: (id: string) => api.post<Wish>(`/api/wishes/${id}/duplicate`),
|
||||||
|
|
||||||
|
uploadImage: (id: string, file: File) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file, file.name);
|
||||||
|
return api.upload<Wish>(`/api/wishes/${id}/image`, fd);
|
||||||
|
},
|
||||||
|
refreshOg: (id: string) => api.post<Wish>(`/api/wishes/${id}/image/refresh-og`),
|
||||||
|
deleteImage: (id: string) => api.delete<Wish>(`/api/wishes/${id}/image`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function statusToQuery(s: WishStatus): OwnerStatus {
|
||||||
|
return s.toLowerCase() as OwnerStatus;
|
||||||
|
}
|
||||||
156
apps/frontend/src/features/wishes/wishes.hooks.ts
Normal file
156
apps/frontend/src/features/wishes/wishes.hooks.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { CreateWishInput, UpdateWishInput } from '@family-wishlist/shared';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { wishesApi, type OwnerStatus } from './wishes.api';
|
||||||
|
import { ApiError } from '@/lib/api';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
const LIST_KEY = (status: OwnerStatus) => ['wishes', status] as const;
|
||||||
|
|
||||||
|
function invalidateAll(client: ReturnType<typeof useQueryClient>): void {
|
||||||
|
void client.invalidateQueries({ queryKey: ['wishes'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toastError(err: unknown, fallback: string): void {
|
||||||
|
if (err instanceof ApiError) toast.error(err.message);
|
||||||
|
else toast.error(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWishes(status: OwnerStatus) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: LIST_KEY(status),
|
||||||
|
queryFn: () => wishesApi.list(status),
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateWish() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateWishInput) => wishesApi.create(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll(client);
|
||||||
|
toast.success(t('toast.wishAdded'));
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateWish() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: { id: string; input: UpdateWishInput }) =>
|
||||||
|
wishesApi.update(vars.id, vars.input),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll(client);
|
||||||
|
toast.success(t('toast.saved'));
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useArchiveWish() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => wishesApi.archive(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll(client);
|
||||||
|
toast.success(t('toast.archived'));
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompleteWish() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => wishesApi.complete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll(client);
|
||||||
|
toast.success(t('toast.fulfilled'));
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteWish() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => wishesApi.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll(client);
|
||||||
|
toast.success(t('toast.deleted'));
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRestoreWish() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => wishesApi.restore(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll(client);
|
||||||
|
toast.success(t('toast.restored'));
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDuplicateWish() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => wishesApi.duplicate(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll(client);
|
||||||
|
toast.success(t('toast.duplicated'));
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadWishImage() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: { id: string; file: File }) => wishesApi.uploadImage(vars.id, vars.file),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll(client);
|
||||||
|
toast.success(t('toast.imageUpdated'));
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefreshOg() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => wishesApi.refreshOg(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll(client);
|
||||||
|
toast.success(t('toast.imageRefreshed'));
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err, t('toast.imageFetchFailed')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResetWishImage() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const { t } = useI18n();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => wishesApi.deleteImage(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll(client);
|
||||||
|
toast.success(t('toast.imageReset'));
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err, t('toast.genericError')),
|
||||||
|
});
|
||||||
|
}
|
||||||
339
apps/frontend/src/i18n/i18n.tsx
Normal file
339
apps/frontend/src/i18n/i18n.tsx
Normal 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');
|
||||||
|
}
|
||||||
70
apps/frontend/src/lib/api.ts
Normal file
70
apps/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export interface ApiErrorShape {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly details?: unknown;
|
||||||
|
constructor(status: number, body: ApiErrorShape) {
|
||||||
|
super(body.message);
|
||||||
|
this.status = status;
|
||||||
|
this.code = body.error;
|
||||||
|
this.details = body.details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions<TBody = unknown> {
|
||||||
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||||
|
body?: TBody;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
formData?: FormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<TResponse, TBody = unknown>(
|
||||||
|
path: string,
|
||||||
|
options: RequestOptions<TBody> = {},
|
||||||
|
): Promise<TResponse> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
let body: BodyInit | undefined;
|
||||||
|
|
||||||
|
if (options.formData) {
|
||||||
|
body = options.formData;
|
||||||
|
} else if (options.body !== undefined) {
|
||||||
|
headers['content-type'] = 'application/json';
|
||||||
|
body = JSON.stringify(options.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
credentials: 'include',
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||||
|
const payload = isJson ? await response.json().catch(() => null) : null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errBody: ApiErrorShape =
|
||||||
|
payload && typeof payload === 'object' && 'error' in payload
|
||||||
|
? (payload as ApiErrorShape)
|
||||||
|
: { error: 'HTTP', message: response.statusText || 'Request failed' };
|
||||||
|
throw new ApiError(response.status, errBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as TResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string, signal?: AbortSignal) => request<T>(path, { signal }),
|
||||||
|
post: <T, B = unknown>(path: string, body?: B) => request<T, B>(path, { method: 'POST', body }),
|
||||||
|
patch: <T, B = unknown>(path: string, body?: B) =>
|
||||||
|
request<T, B>(path, { method: 'PATCH', body }),
|
||||||
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
|
upload: <T>(path: string, formData: FormData) =>
|
||||||
|
request<T>(path, { method: 'POST', formData }),
|
||||||
|
};
|
||||||
6
apps/frontend/src/lib/cn.ts
Normal file
6
apps/frontend/src/lib/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import clsx, { type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
29
apps/frontend/src/lib/format.ts
Normal file
29
apps/frontend/src/lib/format.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export function formatPrice(
|
||||||
|
price: string | null | undefined,
|
||||||
|
currency: string,
|
||||||
|
locale?: string,
|
||||||
|
): string | null {
|
||||||
|
if (!price) return null;
|
||||||
|
const n = Number(price);
|
||||||
|
if (Number.isNaN(n)) return `${price} ${currency}`;
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(n);
|
||||||
|
} catch {
|
||||||
|
return `${n.toLocaleString(locale)} ${currency}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso: string | Date, locale?: string): string {
|
||||||
|
const d = typeof iso === 'string' ? new Date(iso) : iso;
|
||||||
|
return d.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysLeftUntil(iso: string | Date, retentionDays: number): number {
|
||||||
|
const d = typeof iso === 'string' ? new Date(iso) : iso;
|
||||||
|
const expires = d.getTime() + retentionDays * 24 * 60 * 60 * 1000;
|
||||||
|
return Math.max(0, Math.ceil((expires - Date.now()) / (24 * 60 * 60 * 1000)));
|
||||||
|
}
|
||||||
4
apps/frontend/src/lib/version.ts
Normal file
4
apps/frontend/src/lib/version.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare const __FRONTEND_VERSION__: string;
|
||||||
|
|
||||||
|
export const FRONTEND_VERSION: string =
|
||||||
|
typeof __FRONTEND_VERSION__ !== 'undefined' ? __FRONTEND_VERSION__ : '0.0.0';
|
||||||
13
apps/frontend/src/main.tsx
Normal file
13
apps/frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
import './styles/global.css';
|
||||||
|
|
||||||
|
const el = document.getElementById('root');
|
||||||
|
if (!el) throw new Error('#root not found');
|
||||||
|
|
||||||
|
createRoot(el).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
50
apps/frontend/src/pages/ArchivePage.tsx
Normal file
50
apps/frontend/src/pages/ArchivePage.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { WishCard } from '@/components/WishCard/WishCard';
|
||||||
|
import {
|
||||||
|
useDeleteWish,
|
||||||
|
useRestoreWish,
|
||||||
|
useWishes,
|
||||||
|
} from '@/features/wishes/wishes.hooks';
|
||||||
|
import { Archive } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
export function ArchivePage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { data, isLoading } = useWishes('archived');
|
||||||
|
const restore = useRestoreWish();
|
||||||
|
const remove = useDeleteWish();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<section>
|
||||||
|
<h1 className="font-display text-3xl">{t('archive.title')}</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{t('archive.description')}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
|
||||||
|
|
||||||
|
{!isLoading && data && data.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||||
|
<Archive className="h-10 w-10 text-muted" />
|
||||||
|
<h2 className="text-xl font-semibold">{t('archive.emptyTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted">{t('archive.emptyText')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && data.length > 0 && (
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{data.map((wish) => (
|
||||||
|
<WishCard
|
||||||
|
key={wish.id}
|
||||||
|
wish={wish}
|
||||||
|
view="owner"
|
||||||
|
onRestore={() => restore.mutate(wish.id)}
|
||||||
|
onDelete={() => remove.mutate(wish.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/frontend/src/pages/CompletedPage.tsx
Normal file
52
apps/frontend/src/pages/CompletedPage.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
|
import { WishCard } from '@/components/WishCard/WishCard';
|
||||||
|
import {
|
||||||
|
useDeleteWish,
|
||||||
|
useDuplicateWish,
|
||||||
|
useWishes,
|
||||||
|
} from '@/features/wishes/wishes.hooks';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
export function CompletedPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { data, isLoading } = useWishes('completed');
|
||||||
|
const duplicate = useDuplicateWish();
|
||||||
|
const remove = useDeleteWish();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<section>
|
||||||
|
<h1 className="font-display text-3xl">{t('completed.title')}</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{t('completed.description')}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
|
||||||
|
|
||||||
|
{!isLoading && data && data.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||||
|
<CheckCircle2 className="h-10 w-10 text-muted" />
|
||||||
|
<h2 className="text-xl font-semibold">{t('completed.emptyTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{t('completed.emptyText')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && data.length > 0 && (
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{data.map((wish) => (
|
||||||
|
<WishCard
|
||||||
|
key={wish.id}
|
||||||
|
wish={wish}
|
||||||
|
view="owner"
|
||||||
|
onDuplicate={() => duplicate.mutate(wish.id)}
|
||||||
|
onDelete={() => remove.mutate(wish.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
apps/frontend/src/pages/DashboardPage.tsx
Normal file
104
apps/frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Sparkles } from 'lucide-react';
|
||||||
|
import type { Wish } from '@family-wishlist/shared';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { WishCard } from '@/components/WishCard/WishCard';
|
||||||
|
import { WishForm } from '@/components/WishForm/WishForm';
|
||||||
|
import {
|
||||||
|
useArchiveWish,
|
||||||
|
useCompleteWish,
|
||||||
|
useDeleteWish,
|
||||||
|
useWishes,
|
||||||
|
} from '@/features/wishes/wishes.hooks';
|
||||||
|
import { useAuthStore } from '@/features/auth/authStore';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { data, isLoading } = useWishes('active');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Wish | null>(null);
|
||||||
|
const archive = useArchiveWish();
|
||||||
|
const complete = useCompleteWish();
|
||||||
|
const remove = useDeleteWish();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<section className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl">{t('dashboard.title')}</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{t('dashboard.description')}
|
||||||
|
{user && (
|
||||||
|
<Link
|
||||||
|
to={`/u/${user.slug}`}
|
||||||
|
className="font-medium text-primary hover:text-primary-600"
|
||||||
|
>
|
||||||
|
/u/{user.slug}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="lg" onClick={() => setCreating(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t('dashboard.addWish')}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="wish-card animate-pulse"
|
||||||
|
style={{ minHeight: 320, background: 'rgb(var(--color-surface-muted))' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && data && data.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||||
|
<img src="/empty-state.svg" alt="" className="h-40 w-40 opacity-90" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">{t('dashboard.emptyTitle')}</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
{t('dashboard.emptyText')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setCreating(true)}>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
{t('dashboard.addFirstWish')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && data && data.length > 0 && (
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{data.map((wish) => (
|
||||||
|
<WishCard
|
||||||
|
key={wish.id}
|
||||||
|
wish={wish}
|
||||||
|
view="owner"
|
||||||
|
onEdit={() => setEditing(wish)}
|
||||||
|
onArchive={() => archive.mutate(wish.id)}
|
||||||
|
onComplete={() => complete.mutate(wish.id)}
|
||||||
|
onDelete={() => remove.mutate(wish.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<WishForm open={creating} mode="create" onClose={() => setCreating(false)} />
|
||||||
|
<WishForm
|
||||||
|
open={editing !== null}
|
||||||
|
mode="edit"
|
||||||
|
initial={editing ?? undefined}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
apps/frontend/src/pages/LoginPage.tsx
Normal file
108
apps/frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Gift, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { loginSchema, type LoginInput } from '@family-wishlist/shared';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Label } from '@/components/ui/Label';
|
||||||
|
import { useAuthStore } from '@/features/auth/authStore';
|
||||||
|
import { ApiError } from '@/lib/api';
|
||||||
|
import { Footer } from '@/components/Layout/Footer';
|
||||||
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||||
|
import { translateValidation, useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { user, login } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<LoginInput>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
defaultValues: { username: '', password: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) navigate(from, { replace: true });
|
||||||
|
}, [user, from, navigate]);
|
||||||
|
|
||||||
|
if (user) return <Navigate to={from} replace />;
|
||||||
|
|
||||||
|
const submit = handleSubmit(async (values) => {
|
||||||
|
try {
|
||||||
|
await login(values.username, values.password);
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) toast.error(err.message);
|
||||||
|
else toast.error(t('login.failed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<div className="container-page flex justify-end pt-6">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
<div className="container-page flex flex-1 items-center justify-center py-12">
|
||||||
|
<div className="w-full max-w-md animate-fade-in-up">
|
||||||
|
<div className="mb-6 flex items-center justify-center gap-2">
|
||||||
|
<span className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
|
||||||
|
<Gift className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<h1 className="font-display text-3xl">{t('app.name')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border bg-surface p-6 shadow-card sm:p-8">
|
||||||
|
<h2 className="mb-1 text-xl font-semibold">{t('login.title')}</h2>
|
||||||
|
<p className="mb-6 text-sm text-muted">
|
||||||
|
{t('login.description')}
|
||||||
|
</p>
|
||||||
|
<form className="grid gap-4" onSubmit={submit}>
|
||||||
|
<div className="field">
|
||||||
|
<Label htmlFor="username">{t('login.username')}</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
{...register('username')}
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<span className="field__error">
|
||||||
|
{translateValidation(t, errors.username.message)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<Label htmlFor="password">{t('login.password')}</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<span className="field__error">
|
||||||
|
{translateValidation(t, errors.password.message)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="lg" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{t('login.submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/frontend/src/pages/NotFoundPage.tsx
Normal file
18
apps/frontend/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
export function NotFoundPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-6">
|
||||||
|
<div className="max-w-md rounded-xl border border-border bg-surface p-8 text-center shadow-card">
|
||||||
|
<h1 className="font-display text-4xl">404</h1>
|
||||||
|
<p className="mt-2 text-muted">{t('notFound.text')}</p>
|
||||||
|
<Link to="/" className="mt-4 inline-block">
|
||||||
|
<Button variant="secondary">{t('common.backHome')}</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
apps/frontend/src/pages/ProfileSettingsPage.tsx
Normal file
212
apps/frontend/src/pages/ProfileSettingsPage.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
updateProfileSchema,
|
||||||
|
type UpdateProfileInput,
|
||||||
|
type Profile,
|
||||||
|
} from '@family-wishlist/shared';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Gift, Loader2, Upload } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Label } from '@/components/ui/Label';
|
||||||
|
import { Textarea } from '@/components/ui/Textarea';
|
||||||
|
import { api, ApiError } from '@/lib/api';
|
||||||
|
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() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const refresh = useAuthStore((s) => s.refresh);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['profile'],
|
||||||
|
queryFn: () => api.get<Profile>('/api/profile'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
formState: { errors, isSubmitting, isDirty },
|
||||||
|
} = useForm<UpdateProfileInput>({
|
||||||
|
resolver: zodResolver(updateProfileSchema),
|
||||||
|
defaultValues: { slug: '', displayName: '', bio: '', avatarUrl: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
reset({
|
||||||
|
slug: data.slug,
|
||||||
|
displayName: data.displayName,
|
||||||
|
bio: data.bio ?? '',
|
||||||
|
avatarUrl: data.avatarUrl ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, reset]);
|
||||||
|
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: (values: UpdateProfileInput) =>
|
||||||
|
api.patch<Profile, UpdateProfileInput>('/api/profile', values),
|
||||||
|
onSuccess: (p) => {
|
||||||
|
toast.success(t('profile.saved'));
|
||||||
|
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'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 payload: UpdateProfileInput = {
|
||||||
|
...values,
|
||||||
|
bio: values.bio ? values.bio : null,
|
||||||
|
avatarUrl: values.avatarUrl ? values.avatarUrl : null,
|
||||||
|
};
|
||||||
|
update.mutate(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatarPreview = watch('avatarUrl') || data?.avatarUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid max-w-2xl gap-6">
|
||||||
|
<section>
|
||||||
|
<h1 className="font-display text-3xl">{t('profile.title')}</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{t('profile.publicPage')}
|
||||||
|
<code className="rounded bg-ink/5 px-1.5 py-0.5">/u/{data?.slug ?? '...'}</code>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted">{t('common.loading')}</div>
|
||||||
|
) : (
|
||||||
|
<form className="grid gap-4 rounded-xl border border-border bg-surface p-6 shadow-card" onSubmit={submit}>
|
||||||
|
<section className="flex flex-wrap items-center gap-4 rounded-md border border-border bg-surface-muted p-4">
|
||||||
|
<span className="inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-primary text-primary-foreground">
|
||||||
|
{avatarPreview ? (
|
||||||
|
<img src={avatarPreview} alt="" className="h-16 w-16 object-cover" />
|
||||||
|
) : (
|
||||||
|
<Gift className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="text-sm font-semibold">{t('profile.avatar')}</h2>
|
||||||
|
<p className="text-xs text-muted">{t('profile.avatarHint')}</p>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<Label htmlFor="slug">{t('profile.slug')}</Label>
|
||||||
|
<Input id="slug" {...register('slug')} />
|
||||||
|
{errors.slug && (
|
||||||
|
<span className="field__error">{translateValidation(t, errors.slug.message)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<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')} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<Label htmlFor="avatarUrl">{t('profile.avatarUrl')}</Label>
|
||||||
|
<Input id="avatarUrl" inputMode="url" {...register('avatarUrl')} />
|
||||||
|
{errors.avatarUrl && (
|
||||||
|
<span className="field__error">
|
||||||
|
{translateValidation(t, errors.avatarUrl.message)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button type="submit" disabled={!isDirty || isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{t('common.saveChanges')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/frontend/src/pages/PublicProfilePage.tsx
Normal file
130
apps/frontend/src/pages/PublicProfilePage.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
PublicProfile,
|
||||||
|
Wish,
|
||||||
|
} from '@family-wishlist/shared';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { WishCard } from '@/components/WishCard/WishCard';
|
||||||
|
import { Footer } from '@/components/Layout/Footer';
|
||||||
|
import { Gift } from 'lucide-react';
|
||||||
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
import { useAuthStore } from '@/features/auth/authStore';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
export function PublicProfilePage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const { slug = '' } = useParams<{ slug: string }>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const profile = useQuery({
|
||||||
|
queryKey: ['public-profile', slug],
|
||||||
|
queryFn: () => api.get<PublicProfile>(`/api/public/${encodeURIComponent(slug)}`),
|
||||||
|
retry: false,
|
||||||
|
enabled: slug.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wishes = useQuery({
|
||||||
|
queryKey: ['public-wishes', slug],
|
||||||
|
queryFn: () => api.get<Wish[]>(`/api/public/${encodeURIComponent(slug)}/wishes`),
|
||||||
|
enabled: slug.length > 0 && profile.isSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
const markSeen = useMutation({
|
||||||
|
mutationFn: (wishIds: string[]) =>
|
||||||
|
api.post(`/api/public/${encodeURIComponent(slug)}/views`, { wishIds }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const marked = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wishes.data || marked.current) return;
|
||||||
|
const newIds = wishes.data.filter((w) => w.isNewForGuest).map((w) => w.id);
|
||||||
|
if (newIds.length === 0) {
|
||||||
|
marked.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
markSeen.mutate(newIds, {
|
||||||
|
onSuccess: () => {
|
||||||
|
marked.current = true;
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['public-wishes', slug] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [wishes.data, markSeen, queryClient, slug]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<div className="container-page flex items-center justify-between gap-3 pt-6">
|
||||||
|
{user ? (
|
||||||
|
<Link to="/">
|
||||||
|
<Button variant="secondary" size="sm">
|
||||||
|
{t('public.backToMine')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
<main className="container-page flex-1 py-10">
|
||||||
|
{profile.isLoading && <div className="text-muted">{t('common.loading')}</div>}
|
||||||
|
|
||||||
|
{profile.isError && (
|
||||||
|
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface p-8 text-center shadow-card">
|
||||||
|
<h1 className="font-display text-2xl">{t('public.notFoundTitle')}</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted">
|
||||||
|
{t('public.notFoundText')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profile.data && (
|
||||||
|
<>
|
||||||
|
<section className="mb-10 flex flex-col items-center gap-3 text-center">
|
||||||
|
<span className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-card">
|
||||||
|
{profile.data.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={profile.data.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-14 w-14 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Gift className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<h1 className="font-display text-4xl">
|
||||||
|
{t('public.wishlistTitle', { name: profile.data.displayName })}
|
||||||
|
</h1>
|
||||||
|
{profile.data.bio && (
|
||||||
|
<p className="max-w-xl text-muted">{profile.data.bio}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{wishes.isLoading && <div className="text-muted">{t('public.loadingWishes')}</div>}
|
||||||
|
|
||||||
|
{wishes.data && wishes.data.length === 0 && (
|
||||||
|
<div className="mx-auto max-w-lg rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||||
|
<h2 className="text-xl font-semibold">{t('public.emptyTitle')}</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted">{t('public.emptyText')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{wishes.data && wishes.data.length > 0 && (
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{wishes.data.map((wish) => (
|
||||||
|
<WishCard key={wish.id} wish={wish} view="guest" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/frontend/src/pages/TrashPage.tsx
Normal file
58
apps/frontend/src/pages/TrashPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { TRASH_RETENTION_DAYS } from '@family-wishlist/shared';
|
||||||
|
import { WishCard } from '@/components/WishCard/WishCard';
|
||||||
|
import { useRestoreWish, useWishes } from '@/features/wishes/wishes.hooks';
|
||||||
|
import { daysLeftUntil } from '@/lib/format';
|
||||||
|
import { useI18n } from '@/i18n/i18n';
|
||||||
|
|
||||||
|
export function TrashPage() {
|
||||||
|
const { dayCount, t } = useI18n();
|
||||||
|
const { data, isLoading } = useWishes('deleted');
|
||||||
|
const restore = useRestoreWish();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<section>
|
||||||
|
<h1 className="font-display text-3xl">{t('trash.title')}</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{t('trash.description', { days: dayCount(TRASH_RETENTION_DAYS) })}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isLoading && <div className="text-muted">{t('common.loading')}</div>}
|
||||||
|
|
||||||
|
{!isLoading && data && data.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-surface/80 p-10 text-center shadow-card">
|
||||||
|
<Trash2 className="h-10 w-10 text-muted" />
|
||||||
|
<h2 className="text-xl font-semibold">{t('trash.emptyTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{t('trash.emptyText', { days: dayCount(TRASH_RETENTION_DAYS) })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && data.length > 0 && (
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{data.map((wish) => {
|
||||||
|
const left = wish.deletedAt
|
||||||
|
? daysLeftUntil(wish.deletedAt, TRASH_RETENTION_DAYS)
|
||||||
|
: TRASH_RETENTION_DAYS;
|
||||||
|
return (
|
||||||
|
<WishCard
|
||||||
|
key={wish.id}
|
||||||
|
wish={wish}
|
||||||
|
view="owner"
|
||||||
|
onRestore={() => restore.mutate(wish.id)}
|
||||||
|
footer={
|
||||||
|
<p className="mt-2 text-xs font-medium text-warning">
|
||||||
|
{t('trash.autoRemove', { days: dayCount(left) })}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/frontend/src/routes.tsx
Normal file
31
apps/frontend/src/routes.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createBrowserRouter, type RouterProviderProps } from 'react-router-dom';
|
||||||
|
import { ProtectedRoute } from './components/Layout/ProtectedRoute';
|
||||||
|
import { AppShell } from './components/Layout/AppShell';
|
||||||
|
import { LoginPage } from './pages/LoginPage';
|
||||||
|
import { DashboardPage } from './pages/DashboardPage';
|
||||||
|
import { ArchivePage } from './pages/ArchivePage';
|
||||||
|
import { CompletedPage } from './pages/CompletedPage';
|
||||||
|
import { TrashPage } from './pages/TrashPage';
|
||||||
|
import { ProfileSettingsPage } from './pages/ProfileSettingsPage';
|
||||||
|
import { PublicProfilePage } from './pages/PublicProfilePage';
|
||||||
|
import { NotFoundPage } from './pages/NotFoundPage';
|
||||||
|
|
||||||
|
export const router: RouterProviderProps['router'] = createBrowserRouter([
|
||||||
|
{ path: '/login', element: <LoginPage /> },
|
||||||
|
{ path: '/u/:slug', element: <PublicProfilePage /> },
|
||||||
|
{
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppShell />
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{ path: '/', element: <DashboardPage /> },
|
||||||
|
{ path: '/archive', element: <ArchivePage /> },
|
||||||
|
{ path: '/completed', element: <CompletedPage /> },
|
||||||
|
{ path: '/trash', element: <TrashPage /> },
|
||||||
|
{ path: '/settings', element: <ProfileSettingsPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '*', element: <NotFoundPage /> },
|
||||||
|
]);
|
||||||
102
apps/frontend/src/styles/global.css
Normal file
102
apps/frontend/src/styles/global.css
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
@import './tokens.css';
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-ink font-sans antialiased;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(1200px 600px at 0% -10%, rgba(244, 192, 78, 0.18), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 100% 110%, rgba(226, 120, 150, 0.18), transparent 55%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
@apply font-display text-ink;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
@apply transition-colors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* BEM-friendly classes: kept parallel to Tailwind utilities where possible. */
|
||||||
|
.wish-card {
|
||||||
|
@apply relative flex flex-col overflow-hidden rounded-lg bg-surface shadow-card transition-transform duration-200;
|
||||||
|
}
|
||||||
|
.wish-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.wish-card--completed {
|
||||||
|
filter: saturate(0.45) brightness(0.98);
|
||||||
|
}
|
||||||
|
.wish-card--completed .wish-card__title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-decoration-color: rgb(var(--color-muted) / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wish-card__image-wrap {
|
||||||
|
@apply relative aspect-[4/3] bg-surface-muted overflow-hidden;
|
||||||
|
}
|
||||||
|
.wish-card__image {
|
||||||
|
@apply h-full w-full object-cover;
|
||||||
|
}
|
||||||
|
.wish-card__body {
|
||||||
|
@apply flex flex-col gap-2 p-4;
|
||||||
|
}
|
||||||
|
.wish-card__title {
|
||||||
|
@apply text-lg font-semibold leading-snug text-ink;
|
||||||
|
}
|
||||||
|
.wish-card__price {
|
||||||
|
@apply text-sm font-medium text-muted;
|
||||||
|
}
|
||||||
|
.wish-card__comment {
|
||||||
|
@apply text-sm text-muted line-clamp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wish-badge {
|
||||||
|
@apply inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wide;
|
||||||
|
}
|
||||||
|
.wish-badge--new {
|
||||||
|
@apply bg-primary text-primary-foreground;
|
||||||
|
}
|
||||||
|
.wish-badge--completed {
|
||||||
|
@apply bg-ink/5 text-ink;
|
||||||
|
}
|
||||||
|
.wish-badge--archived {
|
||||||
|
@apply bg-warning/20 text-warning;
|
||||||
|
}
|
||||||
|
.wish-badge--deleted {
|
||||||
|
@apply bg-danger/15 text-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
@apply flex flex-col gap-1.5;
|
||||||
|
}
|
||||||
|
.field__label {
|
||||||
|
@apply text-sm font-medium text-ink;
|
||||||
|
}
|
||||||
|
.field__input,
|
||||||
|
.field__textarea,
|
||||||
|
.field__select {
|
||||||
|
@apply w-full rounded-md border border-border bg-surface px-3 py-2.5 text-ink outline-none transition-all duration-150 placeholder:text-muted focus:border-primary focus:shadow-[var(--shadow-focus)];
|
||||||
|
}
|
||||||
|
.field__error {
|
||||||
|
@apply text-xs text-danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.container-page {
|
||||||
|
@apply mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/frontend/src/styles/tokens.css
Normal file
35
apps/frontend/src/styles/tokens.css
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
:root {
|
||||||
|
/* Colors (RGB triplets so Tailwind <alpha-value> works) */
|
||||||
|
--color-background: 255 247 240;
|
||||||
|
--color-surface: 255 255 255;
|
||||||
|
--color-surface-muted: 250 242 235;
|
||||||
|
--color-text: 42 33 53;
|
||||||
|
--color-muted: 142 128 153;
|
||||||
|
--color-border: 235 224 215;
|
||||||
|
|
||||||
|
--color-primary: 226 120 150;
|
||||||
|
--color-primary-50: 255 236 243;
|
||||||
|
--color-primary-600: 200 82 118;
|
||||||
|
--color-primary-foreground: 255 255 255;
|
||||||
|
|
||||||
|
--color-accent: 244 192 78;
|
||||||
|
--color-accent-foreground: 70 48 10;
|
||||||
|
|
||||||
|
--color-success: 97 186 129;
|
||||||
|
--color-warning: 240 168 104;
|
||||||
|
--color-danger: 226 107 107;
|
||||||
|
|
||||||
|
/* Radii */
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 14px;
|
||||||
|
--radius-lg: 20px;
|
||||||
|
--radius-xl: 28px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-card: 0 1px 2px rgba(42, 33, 53, 0.04), 0 10px 28px rgba(42, 33, 53, 0.07);
|
||||||
|
--shadow-pop: 0 8px 32px rgba(42, 33, 53, 0.18);
|
||||||
|
--shadow-focus: 0 0 0 3px rgba(226, 120, 150, 0.3);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-base: 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
58
apps/frontend/tailwind.config.ts
Normal file
58
apps/frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Mapped to CSS variables defined in src/styles/tokens.css so the design
|
||||||
|
// tokens stay the single source of truth.
|
||||||
|
background: 'rgb(var(--color-background) / <alpha-value>)',
|
||||||
|
surface: 'rgb(var(--color-surface) / <alpha-value>)',
|
||||||
|
'surface-muted': 'rgb(var(--color-surface-muted) / <alpha-value>)',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'rgb(var(--color-primary) / <alpha-value>)',
|
||||||
|
foreground: 'rgb(var(--color-primary-foreground) / <alpha-value>)',
|
||||||
|
600: 'rgb(var(--color-primary-600) / <alpha-value>)',
|
||||||
|
50: 'rgb(var(--color-primary-50) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'rgb(var(--color-accent) / <alpha-value>)',
|
||||||
|
foreground: 'rgb(var(--color-accent-foreground) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
ink: 'rgb(var(--color-text) / <alpha-value>)',
|
||||||
|
muted: 'rgb(var(--color-muted) / <alpha-value>)',
|
||||||
|
border: 'rgb(var(--color-border) / <alpha-value>)',
|
||||||
|
success: 'rgb(var(--color-success) / <alpha-value>)',
|
||||||
|
warning: 'rgb(var(--color-warning) / <alpha-value>)',
|
||||||
|
danger: 'rgb(var(--color-danger) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
|
display: ['"Fraunces"', 'Georgia', 'serif'],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sm: 'var(--radius-sm)',
|
||||||
|
DEFAULT: 'var(--radius-md)',
|
||||||
|
md: 'var(--radius-md)',
|
||||||
|
lg: 'var(--radius-lg)',
|
||||||
|
xl: 'var(--radius-xl)',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: 'var(--shadow-card)',
|
||||||
|
pop: 'var(--shadow-pop)',
|
||||||
|
focus: 'var(--shadow-focus)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeInUp: {
|
||||||
|
from: { opacity: '0', transform: 'translateY(8px)' },
|
||||||
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in-up': 'fadeInUp 320ms ease-out both',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
20
apps/frontend/tsconfig.app.json
Normal file
20
apps/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": [],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
7
apps/frontend/tsconfig.json
Normal file
7
apps/frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
12
apps/frontend/tsconfig.node.json
Normal file
12
apps/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"],
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "tailwind.config.ts", "postcss.config.js"]
|
||||||
|
}
|
||||||
36
apps/frontend/vite.config.ts
Normal file
36
apps/frontend/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8')) as {
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
const apiTarget = env.VITE_API_TARGET ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__FRONTEND_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: apiTarget, changeOrigin: true },
|
||||||
|
'/uploads': { target: apiTarget, changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
16
docker-compose.dev.yml
Normal file
16
docker-compose.dev.yml
Normal 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
50
docker-compose.yml
Normal 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
52
docker/backend.Dockerfile
Normal 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"]
|
||||||
30
docker/frontend.Dockerfile
Normal file
30
docker/frontend.Dockerfile
Normal 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
38
docker/nginx.conf
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Static files with long cache
|
||||||
|
location ~* \.(?:js|css|woff2?|svg|png|jpg|jpeg|gif|webp|ico)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,19 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.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"
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
3647
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user