Resolve linter issues, add unit tests, adjust test coverage

This commit is contained in:
2026-02-07 12:24:39 -03:00
parent 430248a4ef
commit 3264b12ea6
45 changed files with 12143 additions and 7918 deletions

View File

@@ -13,9 +13,9 @@ steps:
- name: Docker image build (qr-api + qr-web, multi-arch) - name: Docker image build (qr-api + qr-web, multi-arch)
image: docker:latest image: docker:latest
environment: environment:
DOCKER_API_VERSION: "1.43" DOCKER_API_VERSION: '1.43'
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: '1'
BUILDKIT_PROGRESS: "plain" BUILDKIT_PROGRESS: 'plain'
REGISTRY_URL: git.mifi.dev REGISTRY_URL: git.mifi.dev
REGISTRY_REPO_API: git.mifi.dev/mifi-holdings/shorty-qr-api REGISTRY_REPO_API: git.mifi.dev/mifi-holdings/shorty-qr-api
REGISTRY_REPO_WEB: git.mifi.dev/mifi-holdings/shorty-qr-web REGISTRY_REPO_WEB: git.mifi.dev/mifi-holdings/shorty-qr-web
@@ -52,6 +52,34 @@ steps:
build_push ./qr-web $REGISTRY_REPO_WEB build_push ./qr-web $REGISTRY_REPO_WEB
echo "✓ Images built and pushed (multi-arch)" echo "✓ Images built and pushed (multi-arch)"
- name: Send Build Status Notification (success)
image: curlimages/curl
environment:
DISCORD_WEBHOOK_URL:
from_secret: discord_webhook_url
commands:
- |
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Docker images build success 🎉"}' "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
depends_on:
- Docker image build (qr-api + qr-web, multi-arch)
when:
- status: [success]
- name: Send Build Status Notification (failure)
image: curlimages/curl
environment:
DISCORD_WEBHOOK_URL:
from_secret: discord_webhook_url
commands:
- |
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Docker images build failure 💩"}' "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
depends_on:
- Docker image build (qr-api + qr-web, multi-arch)
when:
- status: [failure]
- name: Trigger Portainer stack redeploy - name: Trigger Portainer stack redeploy
image: curlimages/curl:latest image: curlimages/curl:latest
environment: environment:
@@ -71,3 +99,31 @@ steps:
echo "✓ Portainer redeploy triggered (HTTP $code)" echo "✓ Portainer redeploy triggered (HTTP $code)"
depends_on: depends_on:
- Docker image build (qr-api + qr-web, multi-arch) - Docker image build (qr-api + qr-web, multi-arch)
- name: Send Deploy Status Notification (success)
image: curlimages/curl
environment:
DISCORD_WEBHOOK_URL:
from_secret: discord_webhook_url
commands:
- |
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Production Deploy success 🎉"}' "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [success]
- name: Send Deploy Status Notification (failure)
image: curlimages/curl
environment:
DISCORD_WEBHOOK_URL:
from_secret: discord_webhook_url
commands:
- |
BODY=$(printf '{"username":"WoodpeckerBot","content":"[%s - Build #%s] Production Deploy failure 💩"}' "$CI_REPO" "$CI_PIPELINE_NUMBER")
curl -sS -X POST -H "Content-Type: application/json" -d "$BODY" "$DISCORD_WEBHOOK_URL"
depends_on:
- Trigger Portainer stack redeploy
when:
- status: [failure]

View File

@@ -37,14 +37,14 @@ shorty/
## Key scripts (from repo root) ## Key scripts (from repo root)
| Command | Effect | | Command | Effect |
|-------------------|--------| | ----------------------- | ------------------------------------------ |
| `pnpm install` | Install deps for all workspaces | | `pnpm install` | Install deps for all workspaces |
| `pnpm run lint` | ESLint in qr-api and qr-web | | `pnpm run lint` | ESLint in qr-api and qr-web |
| `pnpm run format:check` | Prettier check (no write) | | `pnpm run format:check` | Prettier check (no write) |
| `pnpm run format` | Prettier write | | `pnpm run format` | Prettier write |
| `pnpm run test` | Vitest in qr-api and qr-web | | `pnpm run test` | Vitest in qr-api and qr-web |
| `pnpm run build` | Build qr-api (tsc) and qr-web (next build) | | `pnpm run build` | Build qr-api (tsc) and qr-web (next build) |
Per-package: `pnpm --filter qr-api dev`, `pnpm --filter qr-web dev` (dev servers). Per-package: `pnpm --filter qr-api dev`, `pnpm --filter qr-web dev` (dev servers).
@@ -70,7 +70,7 @@ Per-package: `pnpm --filter qr-api dev`, `pnpm --filter qr-web dev` (dev servers
- **TypeScript** only (qr-api and qr-web). - **TypeScript** only (qr-api and qr-web).
- **Prettier:** 4 spaces, single quotes, trailing comma all, semicolons (see `.prettierrc`). Check with `pnpm run format:check`. - **Prettier:** 4 spaces, single quotes, trailing comma all, semicolons (see `.prettierrc`). Check with `pnpm run format:check`.
- **ESLint:** Root `.eslintrc.cjs` for qr-api; qr-web has its own `.eslintrc.cjs` (Next + Prettier). No TSLint. - **ESLint:** Root `.eslintrc.cjs` for qr-api; qr-web has its own `.eslintrc.cjs` (Next + Prettier). No TSLint.
- **Ignores:** `.gitignore` (e.g. node_modules, .pnpm-store, .next, dist, .env, *.tsbuildinfo); `.prettierignore` and ESLint `ignorePatterns` aligned (coverage, build dirs, .pnpm-store). Each app has `.dockerignore` to keep build context small. - **Ignores:** `.gitignore` (e.g. node_modules, .pnpm-store, .next, dist, .env, \*.tsbuildinfo); `.prettierignore` and ESLint `ignorePatterns` aligned (coverage, build dirs, .pnpm-store). Each app has `.dockerignore` to keep build context small.
## Where to change what ## Where to change what

View File

@@ -10,14 +10,14 @@ Designed for Docker/Portainer with Traefik. Uses **pnpm** everywhere; no Tailwin
## Prerequisites ## Prerequisites
- **Traefik** with: - **Traefik** with:
- External network `marina-net` (create with `docker network create marina-net` if needed) - External network `marina-net` (create with `docker network create marina-net` if needed)
- Cert resolver (e.g. `letsencrypt` or `lets-encrypt` — adjust labels in `docker-compose.yml` to match your Traefik) - Cert resolver (e.g. `letsencrypt` or `lets-encrypt` — adjust labels in `docker-compose.yml` to match your Traefik)
- **DNS**: A records for `mifi.me`, `link.mifi.me`, `qr.mifi.dev` pointing to the host running Traefik - **DNS**: A records for `mifi.me`, `link.mifi.me`, `qr.mifi.dev` pointing to the host running Traefik
- **Bind mount paths** on the host (create if missing): - **Bind mount paths** on the host (create if missing):
- `/mnt/config/docker/kutt/postgres` — Kutt Postgres data - `/mnt/config/docker/kutt/postgres` — Kutt Postgres data
- `/mnt/config/docker/kutt/redis` — Kutt Redis data - `/mnt/config/docker/kutt/redis` — Kutt Redis data
- `/mnt/config/docker/qr/db` — qr-api SQLite directory - `/mnt/config/docker/qr/db` — qr-api SQLite directory
- `/mnt/config/docker/qr/uploads` — qr-api uploads (logos) - `/mnt/config/docker/qr/uploads` — qr-api uploads (logos)
## Kutt setup ## Kutt setup
@@ -32,8 +32,8 @@ Use prebuilt images and redeploy on push via webhook:
1. In Portainer: **Stacks → Add stack**. Use **docker-compose.portainer.yml** (paste or pull from repo). 1. In Portainer: **Stacks → Add stack**. Use **docker-compose.portainer.yml** (paste or pull from repo).
2. Set env vars: 2. Set env vars:
- **Required:** `DB_PASSWORD`, `JWT_SECRET` - **Required:** `DB_PASSWORD`, `JWT_SECRET`
- **Optional:** `REGISTRY` (default `git.mifi.dev`), `IMAGE_TAG` (default `latest`), `KUTT_API_KEY` - **Optional:** `REGISTRY` (default `git.mifi.dev`), `IMAGE_TAG` (default `latest`), `KUTT_API_KEY`
3. Deploy. Then in the stack: **Webhooks** → add webhook. Copy the URL and add it as secret `portainer_webhook_url` in Woodpecker (repo secrets). On each push to `main`, the pipeline builds multi-arch images, pushes to the registry, and triggers this webhook to redeploy the stack. 3. Deploy. Then in the stack: **Webhooks** → add webhook. Copy the URL and add it as secret `portainer_webhook_url` in Woodpecker (repo secrets). On each push to `main`, the pipeline builds multi-arch images, pushes to the registry, and triggers this webhook to redeploy the stack.
**Option B — Build from source** **Option B — Build from source**
@@ -70,8 +70,8 @@ For local dev without Traefik, you can add a `ports` override for qr_web (e.g. `
1. Open the repo in VS Code/Cursor and use **Dev Containers: Reopen in Container** (or Codespaces). 1. Open the repo in VS Code/Cursor and use **Dev Containers: Reopen in Container** (or Codespaces).
2. `pnpm install` runs automatically. Env vars for qr-api are set in `devcontainer.json` (`DB_PATH`, `UPLOADS_PATH`, `KUTT_API_KEY`, `QR_API_URL`) so you can run qr-api and qr-web without a `.env` file. 2. `pnpm install` runs automatically. Env vars for qr-api are set in `devcontainer.json` (`DB_PATH`, `UPLOADS_PATH`, `KUTT_API_KEY`, `QR_API_URL`) so you can run qr-api and qr-web without a `.env` file.
3. In the container, start the apps: 3. In the container, start the apps:
- **qr-api:** `pnpm --filter qr-api dev` (listens on 8080) - **qr-api:** `pnpm --filter qr-api dev` (listens on 8080)
- **qr-web:** `pnpm --filter qr-web dev` (listens on 3000) - **qr-web:** `pnpm --filter qr-web dev` (listens on 3000)
4. Open the forwarded ports (3000 = qr-web, 8080 = qr-api). Data and uploads are stored under `.data/` in the repo (gitignored). 4. Open the forwarded ports (3000 = qr-web, 8080 = qr-api). Data and uploads are stored under `.data/` in the repo (gitignored).
5. For full stack (Kutt + qr-api + qr-web in Docker), run `docker compose up` from the **host** (or from inside the container if Docker-in-Docker is enabled). Set `DB_PASSWORD`, `JWT_SECRET`, and optionally `KUTT_API_KEY` in `.env` for that. 5. For full stack (Kutt + qr-api + qr-web in Docker), run `docker compose up` from the **host** (or from inside the container if Docker-in-Docker is enabled). Set `DB_PASSWORD`, `JWT_SECRET`, and optionally `KUTT_API_KEY` in `.env` for that.

View File

@@ -15,7 +15,11 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD}
POSTGRES_DB: ${DB_NAME:-kutt} POSTGRES_DB: ${DB_NAME:-kutt}
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-kutt} -d ${DB_NAME:-kutt}"] test:
[
'CMD-SHELL',
'pg_isready -U ${DB_USER:-kutt} -d ${DB_NAME:-kutt}',
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -43,29 +47,29 @@ services:
environment: environment:
DB_CLIENT: pg DB_CLIENT: pg
DB_HOST: kutt_db DB_HOST: kutt_db
DB_PORT: "5432" DB_PORT: '5432'
DB_USER: ${DB_USER:-kutt} DB_USER: ${DB_USER:-kutt}
DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD}
DB_NAME: ${DB_NAME:-kutt} DB_NAME: ${DB_NAME:-kutt}
REDIS_ENABLED: "true" REDIS_ENABLED: 'true'
REDIS_HOST: kutt_redis REDIS_HOST: kutt_redis
REDIS_PORT: "6379" REDIS_PORT: '6379'
DEFAULT_DOMAIN: mifi.me DEFAULT_DOMAIN: mifi.me
NODE_ENV: production NODE_ENV: production
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET} JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET}
labels: labels:
- "traefik.enable=true" - 'traefik.enable=true'
- "docker.network=marina-net" - 'docker.network=marina-net'
- "traefik.http.routers.kutt-mifi.rule=Host(`mifi.me`)" - 'traefik.http.routers.kutt-mifi.rule=Host(`mifi.me`)'
- "traefik.http.routers.kutt-mifi.entrypoints=websecure" - 'traefik.http.routers.kutt-mifi.entrypoints=websecure'
- "traefik.http.routers.kutt-mifi.tls.certresolver=letsencrypt" - 'traefik.http.routers.kutt-mifi.tls.certresolver=letsencrypt'
- "traefik.http.routers.kutt-mifi.service=kutt-short" - 'traefik.http.routers.kutt-mifi.service=kutt-short'
- "traefik.http.services.kutt-short.loadbalancer.server.port=3000" - 'traefik.http.services.kutt-short.loadbalancer.server.port=3000'
- "traefik.http.routers.kutt-link.rule=Host(`link.mifi.me`)" - 'traefik.http.routers.kutt-link.rule=Host(`link.mifi.me`)'
- "traefik.http.routers.kutt-link.entrypoints=websecure" - 'traefik.http.routers.kutt-link.entrypoints=websecure'
- "traefik.http.routers.kutt-link.tls.certresolver=letsencrypt" - 'traefik.http.routers.kutt-link.tls.certresolver=letsencrypt'
- "traefik.http.routers.kutt-link.service=kutt" - 'traefik.http.routers.kutt-link.service=kutt'
- "traefik.http.services.kutt.loadbalancer.server.port=3000" - 'traefik.http.services.kutt.loadbalancer.server.port=3000'
qr_api: qr_api:
image: ${REGISTRY:-git.mifi.dev}/mifi-holdings/shorty-qr-api:${IMAGE_TAG:-latest} image: ${REGISTRY:-git.mifi.dev}/mifi-holdings/shorty-qr-api:${IMAGE_TAG:-latest}
@@ -77,7 +81,7 @@ services:
- /mnt/config/docker/qr/db:/data - /mnt/config/docker/qr/db:/data
- /mnt/config/docker/qr/uploads:/uploads - /mnt/config/docker/qr/uploads:/uploads
environment: environment:
PORT: "8080" PORT: '8080'
DB_PATH: /data/db.sqlite DB_PATH: /data/db.sqlite
UPLOADS_PATH: /uploads UPLOADS_PATH: /uploads
KUTT_API_KEY: ${KUTT_API_KEY:-} KUTT_API_KEY: ${KUTT_API_KEY:-}
@@ -95,15 +99,15 @@ services:
environment: environment:
QR_API_URL: http://qr_api:8080 QR_API_URL: http://qr_api:8080
labels: labels:
- "traefik.enable=true" - 'traefik.enable=true'
- "docker.network=marina-net" - 'docker.network=marina-net'
- "traefik.http.routers.qr-web.rule=Host(`qr.mifi.dev`)" - 'traefik.http.routers.qr-web.rule=Host(`qr.mifi.dev`)'
- "traefik.http.routers.qr-web.entrypoints=websecure" - 'traefik.http.routers.qr-web.entrypoints=websecure'
- "traefik.http.routers.qr-web.tls.certresolver=letsencrypt" - 'traefik.http.routers.qr-web.tls.certresolver=letsencrypt'
- "traefik.http.routers.qr-web.service=qr-web" - 'traefik.http.routers.qr-web.service=qr-web'
- "traefik.http.routers.qr-web.middlewares=qr-web-basicauth" - 'traefik.http.routers.qr-web.middlewares=qr-web-basicauth'
- "traefik.http.middlewares.qr-web-basicauth.basicauth.users=mifi:$$2y$$05$$TS20fkfrmJ3MLc.cgfM6OcuowOstcy/2DTOq0YfirUDU3b0vtNz." - 'traefik.http.middlewares.qr-web-basicauth.basicauth.users=mifi:$$2y$$05$$TS20fkfrmJ3MLc.cgfM6OcuowOstcy/2DTOq0YfirUDU3b0vtNz.'
- "traefik.http.services.qr-web.loadbalancer.server.port=3000" - 'traefik.http.services.qr-web.loadbalancer.server.port=3000'
networks: networks:
marina-net: marina-net:

View File

@@ -11,7 +11,11 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD}
POSTGRES_DB: ${DB_NAME:-kutt} POSTGRES_DB: ${DB_NAME:-kutt}
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-kutt} -d ${DB_NAME:-kutt}"] test:
[
'CMD-SHELL',
'pg_isready -U ${DB_USER:-kutt} -d ${DB_NAME:-kutt}',
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -39,29 +43,29 @@ services:
environment: environment:
DB_CLIENT: pg DB_CLIENT: pg
DB_HOST: kutt_db DB_HOST: kutt_db
DB_PORT: "5432" DB_PORT: '5432'
DB_USER: ${DB_USER:-kutt} DB_USER: ${DB_USER:-kutt}
DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD}
DB_NAME: ${DB_NAME:-kutt} DB_NAME: ${DB_NAME:-kutt}
REDIS_ENABLED: "true" REDIS_ENABLED: 'true'
REDIS_HOST: kutt_redis REDIS_HOST: kutt_redis
REDIS_PORT: "6379" REDIS_PORT: '6379'
DEFAULT_DOMAIN: mifi.me DEFAULT_DOMAIN: mifi.me
NODE_ENV: production NODE_ENV: production
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET} JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET}
labels: labels:
- "traefik.enable=true" - 'traefik.enable=true'
- "docker.network=marina-net" - 'docker.network=marina-net'
- "traefik.http.routers.kutt-mifi.rule=Host(`mifi.me`)" - 'traefik.http.routers.kutt-mifi.rule=Host(`mifi.me`)'
- "traefik.http.routers.kutt-mifi.entrypoints=websecure" - 'traefik.http.routers.kutt-mifi.entrypoints=websecure'
- "traefik.http.routers.kutt-mifi.tls.certresolver=letsencrypt" - 'traefik.http.routers.kutt-mifi.tls.certresolver=letsencrypt'
- "traefik.http.routers.kutt-mifi.service=kutt-short" - 'traefik.http.routers.kutt-mifi.service=kutt-short'
- "traefik.http.services.kutt-short.loadbalancer.server.port=3000" - 'traefik.http.services.kutt-short.loadbalancer.server.port=3000'
- "traefik.http.routers.kutt-link.rule=Host(`link.mifi.me`)" - 'traefik.http.routers.kutt-link.rule=Host(`link.mifi.me`)'
- "traefik.http.routers.kutt-link.entrypoints=websecure" - 'traefik.http.routers.kutt-link.entrypoints=websecure'
- "traefik.http.routers.kutt-link.tls.certresolver=letsencrypt" - 'traefik.http.routers.kutt-link.tls.certresolver=letsencrypt'
- "traefik.http.routers.kutt-link.service=kutt" - 'traefik.http.routers.kutt-link.service=kutt'
- "traefik.http.services.kutt.loadbalancer.server.port=3000" - 'traefik.http.services.kutt.loadbalancer.server.port=3000'
qr_api: qr_api:
build: build:
@@ -75,7 +79,7 @@ services:
- /mnt/config/docker/qr/db:/data - /mnt/config/docker/qr/db:/data
- /mnt/config/docker/qr/uploads:/uploads - /mnt/config/docker/qr/uploads:/uploads
environment: environment:
PORT: "8080" PORT: '8080'
DB_PATH: /data/db.sqlite DB_PATH: /data/db.sqlite
UPLOADS_PATH: /uploads UPLOADS_PATH: /uploads
KUTT_API_KEY: ${KUTT_API_KEY:-} KUTT_API_KEY: ${KUTT_API_KEY:-}
@@ -95,15 +99,15 @@ services:
environment: environment:
QR_API_URL: http://qr_api:8080 QR_API_URL: http://qr_api:8080
labels: labels:
- "traefik.enable=true" - 'traefik.enable=true'
- "docker.network=marina-net" - 'docker.network=marina-net'
- "traefik.http.routers.qr-web.rule=Host(`qr.mifi.dev`)" - 'traefik.http.routers.qr-web.rule=Host(`qr.mifi.dev`)'
- "traefik.http.routers.qr-web.entrypoints=websecure" - 'traefik.http.routers.qr-web.entrypoints=websecure'
- "traefik.http.routers.qr-web.tls.certresolver=letsencrypt" - 'traefik.http.routers.qr-web.tls.certresolver=letsencrypt'
- "traefik.http.routers.qr-web.service=qr-web" - 'traefik.http.routers.qr-web.service=qr-web'
- "traefik.http.routers.qr-web.middlewares=qr-web-basicauth" - 'traefik.http.routers.qr-web.middlewares=qr-web-basicauth'
- "traefik.http.middlewares.qr-web-basicauth.basicauth.users=mifi:$$2y$$05$$TS20fkfrmJ3MLc.cgfM6OcuowOstcy/2DTOq0YfirUDU3b0vtNz." - 'traefik.http.middlewares.qr-web-basicauth.basicauth.users=mifi:$$2y$$05$$TS20fkfrmJ3MLc.cgfM6OcuowOstcy/2DTOq0YfirUDU3b0vtNz.'
- "traefik.http.services.qr-web.loadbalancer.server.port=3000" - 'traefik.http.services.qr-web.loadbalancer.server.port=3000'
networks: networks:
marina-net: marina-net:

View File

@@ -4,11 +4,14 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"lint": "pnpm -r run lint", "build": "pnpm -r run build",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"lint": "pnpm -r run lint",
"lint:fix": "pnpm -r run lint:fix",
"test": "pnpm -r run test", "test": "pnpm -r run test",
"build": "pnpm -r run build" "test:coverage": "pnpm -r run test:coverage",
"test:watch": "pnpm -r run test:watch"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.4.2" "prettier": "^3.4.2"
@@ -20,5 +23,10 @@
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mifi.dev/mifi-holdings/shorty.git" "url": "https://git.mifi.dev/mifi-holdings/shorty.git"
},
"pnpm": {
"overrides": {
"glob": "^13.0.0"
}
} }
} }

17787
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,11 @@
packages: packages:
- "qr-api" - qr-api
- "qr-web" - qr-web
ignoredBuiltDependencies:
- unrs-resolver
onlyBuiltDependencies:
- better-sqlite3
- esbuild
- sharp

28
qr-api/eslint.config.cjs Normal file
View File

@@ -0,0 +1,28 @@
'use strict';
const tsParser = require('@typescript-eslint/parser');
const tsPlugin = require('@typescript-eslint/eslint-plugin');
const prettierConfig = require('eslint-config-prettier');
module.exports = [
{ ignores: ['node_modules/', 'dist/', 'coverage/', '*.tsbuildinfo'] },
{
files: ['**/*.ts'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parser: tsParser,
parserOptions: { project: null },
},
plugins: { '@typescript-eslint': tsPlugin },
rules: {
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
prettierConfig,
];

View File

@@ -5,17 +5,21 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"lint": "eslint src --ext .ts", "format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"start": "node dist/index.js",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^11.6.0", "better-sqlite3": "^11.6.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.1", "express": "^4.21.1",
"multer": "^1.4.5-lts.1", "multer": "^2.0.2",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^11.3.0", "pino-pretty": "^11.3.0",
"zod": "^3.23.8" "zod": "^3.23.8"
@@ -24,7 +28,7 @@
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/multer": "^1.4.12", "@types/multer": "^2.0.0",
"@types/node": "^22.9.0", "@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
@@ -32,7 +36,9 @@
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vitest": "^2.1.6" "vitest": "^2.1.6",
"@vitest/coverage-v8": "^2.1.6",
"supertest": "^7.0.0"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

50
qr-api/src/app.ts Normal file
View File

@@ -0,0 +1,50 @@
import express from 'express';
import cors from 'cors';
import type { Database } from 'better-sqlite3';
import type { Env } from './env.js';
import { projectsRouter } from './routes/projects.js';
import { foldersRouter } from './routes/folders.js';
import { uploadsRouter } from './routes/uploads.js';
import { shortenRouter } from './routes/shorten.js';
export function createApp(
db: Database,
env: Env,
baseUrl = '',
logger?: { error: (o: object, msg?: string) => void },
): express.Express {
const app = express();
app.use(cors());
app.use(express.json());
app.use('/projects', projectsRouter(db, baseUrl));
app.use('/folders', foldersRouter(db));
app.use('/uploads', uploadsRouter(env, baseUrl));
app.use('/shorten', shortenRouter(env));
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.use(
(
err: Error,
_req: express.Request,
res: express.Response,
_next: express.NextFunction,
) => {
const msg = err.message ?? '';
if (
err.name === 'MulterError' ||
msg.includes('image files') ||
msg.includes('file size')
) {
return res.status(400).json({ error: msg || 'Invalid upload' });
}
logger?.error({ err }, 'Unhandled error');
res.status(500).json({ error: 'Internal server error' });
},
);
return app;
}

View File

@@ -1,4 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { import {
initDb, initDb,
@@ -7,6 +10,11 @@ import {
getProject, getProject,
updateProject, updateProject,
deleteProject, deleteProject,
listFolders,
createFolder,
getFolder,
updateFolder,
deleteFolder,
} from './db.js'; } from './db.js';
const testEnv = { const testEnv = {
@@ -25,7 +33,10 @@ describe('db', () => {
}); });
it('creates and gets a project', () => { it('creates and gets a project', () => {
const p = createProject(db, { name: 'Test', originalUrl: 'https://example.com' }); const p = createProject(db, {
name: 'Test',
originalUrl: 'https://example.com',
});
expect(p.id).toBeDefined(); expect(p.id).toBeDefined();
expect(p.name).toBe('Test'); expect(p.name).toBe('Test');
expect(p.originalUrl).toBe('https://example.com'); expect(p.originalUrl).toBe('https://example.com');
@@ -45,7 +56,10 @@ describe('db', () => {
it('updates a project', () => { it('updates a project', () => {
const p = createProject(db, { name: 'Old' }); const p = createProject(db, { name: 'Old' });
const updated = updateProject(db, p.id, { name: 'New', recipeJson: '{"x":1}' }); const updated = updateProject(db, p.id, {
name: 'New',
recipeJson: '{"x":1}',
});
expect(updated?.name).toBe('New'); expect(updated?.name).toBe('New');
expect(updated?.recipeJson).toBe('{"x":1}'); expect(updated?.recipeJson).toBe('{"x":1}');
}); });
@@ -58,8 +72,110 @@ describe('db', () => {
}); });
it('returns null for missing project', () => { it('returns null for missing project', () => {
expect(getProject(db, '00000000-0000-0000-0000-000000000000')).toBeNull(); expect(
expect(updateProject(db, '00000000-0000-0000-0000-000000000000', { name: 'X' })).toBeNull(); getProject(db, '00000000-0000-0000-0000-000000000000'),
expect(deleteProject(db, '00000000-0000-0000-0000-000000000000')).toBe(false); ).toBeNull();
expect(
updateProject(db, '00000000-0000-0000-0000-000000000000', {
name: 'X',
}),
).toBeNull();
expect(deleteProject(db, '00000000-0000-0000-0000-000000000000')).toBe(
false,
);
});
it('updateProject preserves shortUrl, logoFilename, folderId when not provided', () => {
const p = createProject(db, {
name: 'P',
shortUrl: 'https://mifi.me/x',
logoFilename: 'logo.png',
folderId: null,
});
const f = createFolder(db, { name: 'F' });
updateProject(db, p.id, { folderId: f.id });
const updated = getProject(db, p.id)!;
expect(updated.shortUrl).toBe('https://mifi.me/x');
expect(updated.logoFilename).toBe('logo.png');
expect(updated.folderId).toBe(f.id);
});
it('updateProject with explicit logoFilename', () => {
const p = createProject(db, { name: 'P', logoFilename: 'old.png' });
updateProject(db, p.id, { logoFilename: null });
expect(getProject(db, p.id)!.logoFilename).toBeNull();
updateProject(db, p.id, { logoFilename: 'new.png' });
expect(getProject(db, p.id)!.logoFilename).toBe('new.png');
});
it('listFolders returns empty then folders in sort order', () => {
expect(listFolders(db)).toEqual([]);
createFolder(db, { name: 'B', sortOrder: 1 });
createFolder(db, { name: 'A', sortOrder: 0 });
const list = listFolders(db);
expect(list.length).toBe(2);
expect(list[0].name).toBe('A');
expect(list[1].name).toBe('B');
});
it('createFolder defaults name and sortOrder', () => {
const f = createFolder(db, {});
expect(f.id).toBeDefined();
expect(f.name).toBe('Folder');
expect(f.sortOrder).toBe(0);
});
it('getFolder returns folder or null', () => {
const f = createFolder(db, { name: 'X' });
expect(getFolder(db, f.id)?.name).toBe('X');
expect(
getFolder(db, '00000000-0000-0000-0000-000000000000'),
).toBeNull();
});
it('updateFolder and deleteFolder', () => {
const f = createFolder(db, { name: 'Old' });
const updated = updateFolder(db, f.id, { name: 'New', sortOrder: 5 });
expect(updated?.name).toBe('New');
expect(updated?.sortOrder).toBe(5);
expect(
updateFolder(db, '00000000-0000-0000-0000-000000000000', {
name: 'X',
}),
).toBeNull();
const deleted = deleteFolder(db, f.id);
expect(deleted).toBe(true);
expect(getFolder(db, f.id)).toBeNull();
expect(deleteFolder(db, '00000000-0000-0000-0000-000000000000')).toBe(
false,
);
});
it('deleteFolder nulls project folderId', () => {
const folder = createFolder(db, { name: 'F' });
const p = createProject(db, { name: 'P', folderId: folder.id });
deleteFolder(db, folder.id);
expect(getProject(db, p.id)!.folderId).toBeNull();
});
it('initDb tolerates existing folderId column', () => {
const tmp = path.join(os.tmpdir(), `qr-db-${Date.now()}.sqlite`);
try {
const env = { ...testEnv, DB_PATH: tmp } as Parameters<
typeof initDb
>[0];
const db1 = initDb(env);
db1.close();
const db2 = initDb(env);
const list = listFolders(db2);
expect(list).toEqual([]);
db2.close();
} finally {
try {
fs.unlinkSync(tmp);
} catch {
/* ignore */
}
}
}); });
}); });

View File

@@ -69,20 +69,47 @@ export function createProject(
db.prepare( db.prepare(
`INSERT INTO projects (id, name, createdAt, updatedAt, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId) `INSERT INTO projects (id, name, createdAt, updatedAt, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(id, name, now, now, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId); ).run(
id,
name,
now,
now,
originalUrl,
shortenEnabled,
shortUrl,
recipeJson,
logoFilename,
folderId,
);
return getProject(db, id)!; return getProject(db, id)!;
} }
export function listProjects(db: Database.Database): Omit<Project, 'recipeJson' | 'originalUrl' | 'shortUrl' | 'shortenEnabled'>[] { export function listProjects(
const rows = db.prepare( db: Database.Database,
'SELECT id, name, createdAt, updatedAt, logoFilename, folderId FROM projects ORDER BY updatedAt DESC, createdAt DESC, id DESC', ): Omit<
).all() as Array<{ id: string; name: string; createdAt: string; updatedAt: string; logoFilename: string | null; folderId: string | null }>; Project,
'recipeJson' | 'originalUrl' | 'shortUrl' | 'shortenEnabled'
>[] {
const rows = db
.prepare(
'SELECT id, name, createdAt, updatedAt, logoFilename, folderId FROM projects ORDER BY updatedAt DESC, createdAt DESC, id DESC',
)
.all() as Array<{
id: string;
name: string;
createdAt: string;
updatedAt: string;
logoFilename: string | null;
folderId: string | null;
}>;
return rows; return rows;
} }
export function getProject(db: Database.Database, id: string): Project | null { export function getProject(db: Database.Database, id: string): Project | null {
const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as Project | undefined; const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as
| Project
| undefined;
return row ?? null; return row ?? null;
} }
@@ -98,14 +125,29 @@ export function updateProject(
const name = data.name ?? existing.name; const name = data.name ?? existing.name;
const originalUrl = data.originalUrl ?? existing.originalUrl; const originalUrl = data.originalUrl ?? existing.originalUrl;
const shortenEnabled = data.shortenEnabled ?? existing.shortenEnabled; const shortenEnabled = data.shortenEnabled ?? existing.shortenEnabled;
const shortUrl = data.shortUrl !== undefined ? data.shortUrl : existing.shortUrl; const shortUrl =
data.shortUrl !== undefined ? data.shortUrl : existing.shortUrl;
const recipeJson = data.recipeJson ?? existing.recipeJson; const recipeJson = data.recipeJson ?? existing.recipeJson;
const logoFilename = data.logoFilename !== undefined ? data.logoFilename : existing.logoFilename; const logoFilename =
const folderId = data.folderId !== undefined ? data.folderId : existing.folderId; data.logoFilename !== undefined
? data.logoFilename
: existing.logoFilename;
const folderId =
data.folderId !== undefined ? data.folderId : existing.folderId;
db.prepare( db.prepare(
`UPDATE projects SET name = ?, updatedAt = ?, originalUrl = ?, shortenEnabled = ?, shortUrl = ?, recipeJson = ?, logoFilename = ?, folderId = ? WHERE id = ?`, `UPDATE projects SET name = ?, updatedAt = ?, originalUrl = ?, shortenEnabled = ?, shortUrl = ?, recipeJson = ?, logoFilename = ?, folderId = ? WHERE id = ?`,
).run(name, updatedAt, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId, id); ).run(
name,
updatedAt,
originalUrl,
shortenEnabled,
shortUrl,
recipeJson,
logoFilename,
folderId,
id,
);
return getProject(db, id); return getProject(db, id);
} }
@@ -116,9 +158,11 @@ export function deleteProject(db: Database.Database, id: string): boolean {
} }
export function listFolders(db: Database.Database): Folder[] { export function listFolders(db: Database.Database): Folder[] {
const rows = db.prepare( const rows = db
'SELECT id, name, sortOrder FROM folders ORDER BY sortOrder ASC, name ASC', .prepare(
).all() as Folder[]; 'SELECT id, name, sortOrder FROM folders ORDER BY sortOrder ASC, name ASC',
)
.all() as Folder[];
return rows; return rows;
} }
@@ -136,7 +180,9 @@ export function createFolder(
} }
export function getFolder(db: Database.Database, id: string): Folder | null { export function getFolder(db: Database.Database, id: string): Folder | null {
const row = db.prepare('SELECT id, name, sortOrder FROM folders WHERE id = ?').get(id) as Folder | undefined; const row = db
.prepare('SELECT id, name, sortOrder FROM folders WHERE id = ?')
.get(id) as Folder | undefined;
return row ?? null; return row ?? null;
} }
@@ -149,12 +195,18 @@ export function updateFolder(
if (!existing) return null; if (!existing) return null;
const name = data.name ?? existing.name; const name = data.name ?? existing.name;
const sortOrder = data.sortOrder ?? existing.sortOrder; const sortOrder = data.sortOrder ?? existing.sortOrder;
db.prepare('UPDATE folders SET name = ?, sortOrder = ? WHERE id = ?').run(name, sortOrder, id); db.prepare('UPDATE folders SET name = ?, sortOrder = ? WHERE id = ?').run(
name,
sortOrder,
id,
);
return getFolder(db, id); return getFolder(db, id);
} }
export function deleteFolder(db: Database.Database, id: string): boolean { export function deleteFolder(db: Database.Database, id: string): boolean {
db.prepare('UPDATE projects SET folderId = NULL WHERE folderId = ?').run(id); db.prepare('UPDATE projects SET folderId = NULL WHERE folderId = ?').run(
id,
);
const result = db.prepare('DELETE FROM folders WHERE id = ?').run(id); const result = db.prepare('DELETE FROM folders WHERE id = ?').run(id);
return result.changes > 0; return result.changes > 0;
} }

37
qr-api/src/env.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadEnv } from './env.js';
describe('loadEnv', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('returns defaults when env is minimal', () => {
process.env = {};
const env = loadEnv();
expect(env.PORT).toBe(8080);
expect(env.DB_PATH).toBe('/data/db.sqlite');
expect(env.UPLOADS_PATH).toBe('/uploads');
expect(env.KUTT_BASE_URL).toBe('http://kutt:3000');
expect(env.SHORT_DOMAIN).toBe('https://mifi.me');
expect(env.KUTT_API_KEY).toBeUndefined();
});
it('parses PORT and overrides defaults', () => {
process.env = { PORT: '3000', KUTT_BASE_URL: 'http://localhost:3000' };
const env = loadEnv();
expect(env.PORT).toBe(3000);
expect(env.KUTT_BASE_URL).toBe('http://localhost:3000');
});
it('throws when env is invalid', () => {
process.env = { KUTT_BASE_URL: 'not-a-url' };
expect(() => loadEnv()).toThrow(/Invalid env/);
});
});

View File

@@ -1,14 +1,9 @@
import express from 'express';
import cors from 'cors';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import pino from 'pino'; import pino from 'pino';
import { loadEnv } from './env.js'; import { loadEnv } from './env.js';
import { initDb } from './db.js'; import { initDb } from './db.js';
import { projectsRouter } from './routes/projects.js'; import { createApp } from './app.js';
import { foldersRouter } from './routes/folders.js';
import { uploadsRouter } from './routes/uploads.js';
import { shortenRouter } from './routes/shorten.js';
const env = loadEnv(); const env = loadEnv();
const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' }); const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });
@@ -24,30 +19,7 @@ for (const dir of [dataDir, uploadsDir]) {
} }
const db = initDb(env); const db = initDb(env);
const app = createApp(db, env, '', logger);
const app = express();
app.use(cors());
app.use(express.json());
const baseUrl = ''; // relative; Next.js proxy will use same origin for /api
app.use('/projects', projectsRouter(db, baseUrl));
app.use('/folders', foldersRouter(db));
app.use('/uploads', uploadsRouter(env, baseUrl));
app.use('/shorten', shortenRouter(env));
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const msg = err.message ?? '';
if (err.name === 'MulterError' || msg.includes('image files') || msg.includes('file size')) {
return res.status(400).json({ error: msg || 'Invalid upload' });
}
logger.error({ err }, 'Unhandled error');
res.status(500).json({ error: 'Internal server error' });
});
const port = env.PORT; const port = env.PORT;
app.listen(port, () => { app.listen(port, () => {

295
qr-api/src/routes.test.ts Normal file
View File

@@ -0,0 +1,295 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import request from 'supertest';
import path from 'path';
import os from 'os';
import fs from 'fs';
import { initDb } from './db.js';
import { createApp } from './app.js';
const testEnv = {
DB_PATH: ':memory:',
UPLOADS_PATH: path.join(os.tmpdir(), `qr-uploads-${Date.now()}`),
PORT: 8080,
KUTT_BASE_URL: 'http://kutt:3000',
SHORT_DOMAIN: 'https://mifi.me',
KUTT_API_KEY: undefined as string | undefined,
};
describe('app routes', () => {
beforeEach(() => {
if (fs.existsSync(testEnv.UPLOADS_PATH)) {
fs.rmSync(testEnv.UPLOADS_PATH, { recursive: true });
}
fs.mkdirSync(testEnv.UPLOADS_PATH, { recursive: true });
});
const db = initDb(testEnv as Parameters<typeof initDb>[0]);
const app = createApp(
db,
testEnv as Parameters<typeof createApp>[1],
'/api',
);
it('GET /health returns ok', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok' });
});
it('projects CRUD', async () => {
const create = await request(app)
.post('/projects')
.send({ name: 'P1', originalUrl: 'https://a.com' });
expect(create.status).toBe(201);
expect(create.body.name).toBe('P1');
const id = create.body.id;
const get = await request(app).get(`/projects/${id}`);
expect(get.status).toBe(200);
expect(get.body.name).toBe('P1');
expect(get.body.logoUrl).toBeNull();
const list = await request(app).get('/projects');
expect(list.status).toBe(200);
expect(list.body).toHaveLength(1);
const update = await request(app)
.put(`/projects/${id}`)
.send({ name: 'P2' });
expect(update.status).toBe(200);
expect(update.body.name).toBe('P2');
const del = await request(app).delete(`/projects/${id}`);
expect(del.status).toBe(204);
const getAfter = await request(app).get(`/projects/${id}`);
expect(getAfter.status).toBe(404);
});
it('projects validation', async () => {
const bad = await request(app).post('/projects').send({ name: 123 });
expect(bad.status).toBe(400);
const noId = await request(app).get('/projects/not-a-uuid');
expect(noId.status).toBe(400);
});
it('projects POST with logoFilename returns logoUrl', async () => {
const create = await request(app)
.post('/projects')
.send({ name: 'WithLogo', logoFilename: 'logo.png' });
expect(create.status).toBe(201);
expect(create.body.logoUrl).toBe('/api/uploads/logo.png');
});
it('projects PUT with shortenEnabled false', async () => {
const create = await request(app)
.post('/projects')
.send({ name: 'P', originalUrl: 'https://x.com' });
const res = await request(app)
.put(`/projects/${create.body.id}`)
.send({ shortenEnabled: false });
expect(res.status).toBe(200);
expect(res.body.shortenEnabled).toBe(false);
});
it('projects PUT invalid body returns 400', async () => {
const create = await request(app).post('/projects').send({ name: 'P' });
const res = await request(app)
.put(`/projects/${create.body.id}`)
.send({ name: 123 });
expect(res.status).toBe(400);
});
it('projects PUT 404 when project does not exist', async () => {
const res = await request(app)
.put('/projects/00000000-0000-0000-0000-000000000000')
.send({ name: 'X' });
expect(res.status).toBe(404);
});
it('projects DELETE 404 when project does not exist', async () => {
const res = await request(app).delete(
'/projects/00000000-0000-0000-0000-000000000000',
);
expect(res.status).toBe(404);
});
it('folders CRUD', async () => {
const create = await request(app).post('/folders').send({ name: 'F1' });
expect(create.status).toBe(201);
expect(create.body.name).toBe('F1');
const id = create.body.id;
const get = await request(app).get(`/folders/${id}`);
expect(get.status).toBe(200);
const list = await request(app).get('/folders');
expect(list.status).toBe(200);
expect(list.body.length).toBeGreaterThanOrEqual(1);
await request(app).put(`/folders/${id}`).send({ name: 'F2' });
const del = await request(app).delete(`/folders/${id}`);
expect(del.status).toBe(204);
});
it('folders validation', async () => {
const noId = await request(app).get('/folders/not-a-uuid');
expect(noId.status).toBe(400);
const notFound = await request(app).get(
'/folders/00000000-0000-0000-0000-000000000000',
);
expect(notFound.status).toBe(404);
});
it('folders POST invalid body returns 400', async () => {
const res = await request(app).post('/folders').send({ name: 123 });
expect(res.status).toBe(400);
});
it('folders PUT invalid id returns 400', async () => {
const res = await request(app)
.put('/folders/not-a-uuid')
.send({ name: 'X' });
expect(res.status).toBe(400);
});
it('folders PUT invalid body returns 400', async () => {
const create = await request(app).post('/folders').send({ name: 'F' });
const res = await request(app)
.put(`/folders/${create.body.id}`)
.send({ name: 999 });
expect(res.status).toBe(400);
});
it('folders PUT 404 when folder does not exist', async () => {
const res = await request(app)
.put('/folders/00000000-0000-0000-0000-000000000000')
.send({ name: 'X' });
expect(res.status).toBe(404);
});
it('folders DELETE 404 when folder does not exist', async () => {
const res = await request(app).delete(
'/folders/00000000-0000-0000-0000-000000000000',
);
expect(res.status).toBe(404);
});
it('shorten returns 503 when KUTT_API_KEY missing', async () => {
const res = await request(app)
.post('/shorten')
.send({ targetUrl: 'https://x.com' });
expect(res.status).toBe(503);
});
it('shorten validates body', async () => {
const res = await request(app).post('/shorten').send({});
expect(res.status).toBe(400);
});
it('uploads/logo returns 400 when no file', async () => {
const res = await request(app).post('/uploads/logo');
expect(res.status).toBe(400);
});
it('uploads/:filename returns 400 for invalid filename', async () => {
const res = await request(app).get('/uploads/..hidden');
expect(res.status).toBe(400);
});
it('uploads/:filename returns 404 for missing file', async () => {
const res = await request(app).get('/uploads/nonexistent.png');
expect(res.status).toBe(404);
});
it('shorten returns 502 when Kutt returns no URL', async () => {
vi.stubGlobal('fetch', vi.fn());
const envWithKey = { ...testEnv, KUTT_API_KEY: 'key' };
const appWithKey = createApp(
db,
envWithKey as Parameters<typeof createApp>[1],
);
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
const res = await request(appWithKey)
.post('/shorten')
.send({ targetUrl: 'https://x.com' });
expect(res.status).toBe(502);
});
it('shorten returns 502 when fetch rejects with non-Error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce('network error'));
const envWithKey = { ...testEnv, KUTT_API_KEY: 'key' };
const appWithKey = createApp(
db,
envWithKey as Parameters<typeof createApp>[1],
);
const res = await request(appWithKey)
.post('/shorten')
.send({ targetUrl: 'https://x.com' });
expect(res.status).toBe(502);
});
it('uploads/logo rejects non-image via error handler', async () => {
const res = await request(app)
.post('/uploads/logo')
.attach('file', Buffer.from('fake pdf'), {
filename: 'x.pdf',
contentType: 'application/pdf',
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('image files');
});
it('uploads/logo accepts image and returns filename', async () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]); // PNG magic
const res = await request(app)
.post('/uploads/logo')
.attach('file', png, {
filename: 'logo.png',
contentType: 'image/png',
});
expect(res.status).toBe(200);
expect(res.body.filename).toBeDefined();
expect(res.body.url).toContain(res.body.filename);
});
it('uploads/logo with no extension uses .bin', async () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const res = await request(app)
.post('/uploads/logo')
.attach('file', png, {
filename: 'noext',
contentType: 'image/png',
});
expect(res.status).toBe(200);
expect(res.body.filename).toMatch(/\.bin$/);
});
it('uploads/:filename returns file when it exists', async () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const upload = await request(app)
.post('/uploads/logo')
.attach('file', png, {
filename: 'logo.png',
contentType: 'image/png',
});
const filename = upload.body.filename;
const get = await request(app).get(`/uploads/${filename}`);
expect(get.status).toBe(200);
});
it('uploads/:filename returns 400 for absolute path', async () => {
const res = await request(app).get(
'/uploads/' + encodeURIComponent('/etc/passwd'),
);
expect(res.status).toBe(400);
});
});

View File

@@ -23,14 +23,19 @@ const updateBodySchema = createBodySchema.partial();
const idParamSchema = z.object({ id: z.string().uuid() }); const idParamSchema = z.object({ id: z.string().uuid() });
export function projectsRouter(db: Database, baseUrl: string): ReturnType<typeof Router> { export function projectsRouter(
db: Database,
baseUrl: string,
): ReturnType<typeof Router> {
const router = Router(); const router = Router();
const toJson = (p: ReturnType<typeof getProject>) => const toJson = (p: ReturnType<typeof getProject>) =>
p p
? { ? {
...p, ...p,
shortenEnabled: Boolean(p.shortenEnabled), shortenEnabled: Boolean(p.shortenEnabled),
logoUrl: p.logoFilename ? `${baseUrl}/uploads/${p.logoFilename}` : null, logoUrl: p.logoFilename
? `${baseUrl}/uploads/${p.logoFilename}`
: null,
} }
: null; : null;
@@ -63,7 +68,9 @@ export function projectsRouter(db: Database, baseUrl: string): ReturnType<typeof
id: p.id, id: p.id,
name: p.name, name: p.name,
updatedAt: p.updatedAt, updatedAt: p.updatedAt,
logoUrl: p.logoFilename ? `${baseUrl}/uploads/${p.logoFilename}` : null, logoUrl: p.logoFilename
? `${baseUrl}/uploads/${p.logoFilename}`
: null,
folderId: p.folderId ?? null, folderId: p.folderId ?? null,
})); }));
return res.json(items); return res.json(items);
@@ -97,7 +104,12 @@ export function projectsRouter(db: Database, baseUrl: string): ReturnType<typeof
const project = updateProject(db, paramParsed.data.id, { const project = updateProject(db, paramParsed.data.id, {
name: data.name, name: data.name,
originalUrl: data.originalUrl, originalUrl: data.originalUrl,
shortenEnabled: data.shortenEnabled !== undefined ? (data.shortenEnabled ? 1 : 0) : undefined, shortenEnabled:
data.shortenEnabled !== undefined
? data.shortenEnabled
? 1
: 0
: undefined,
shortUrl: data.shortUrl, shortUrl: data.shortUrl,
recipeJson: data.recipeJson, recipeJson: data.recipeJson,
logoFilename: data.logoFilename, logoFilename: data.logoFilename,

View File

@@ -4,18 +4,25 @@ import fs from 'fs';
import type { Env } from '../env.js'; import type { Env } from '../env.js';
import { createMulter } from '../upload.js'; import { createMulter } from '../upload.js';
export function uploadsRouter(env: Env, baseUrl: string): ReturnType<typeof Router> { export function uploadsRouter(
env: Env,
baseUrl: string,
): ReturnType<typeof Router> {
const router = Router(); const router = Router();
const upload = createMulter(env); const upload = createMulter(env);
router.post('/logo', upload.single('file'), (req: Request, res: Response) => { router.post(
if (!req.file) { '/logo',
return res.status(400).json({ error: 'No file uploaded' }); upload.single('file'),
} (req: Request, res: Response) => {
const filename = req.file.filename; if (!req.file) {
const url = `${baseUrl}/uploads/${filename}`; return res.status(400).json({ error: 'No file uploaded' });
return res.json({ filename, url }); }
}); const filename = req.file.filename;
const url = `${baseUrl}/uploads/${filename}`;
return res.json({ filename, url });
},
);
router.get('/:filename', (req: Request, res: Response) => { router.get('/:filename', (req: Request, res: Response) => {
const raw = req.params.filename; const raw = req.params.filename;

View File

@@ -20,15 +20,21 @@ describe('shortenUrl', () => {
ok: true, ok: true,
json: () => Promise.resolve({ link: 'https://mifi.me/abc' }), json: () => Promise.resolve({ link: 'https://mifi.me/abc' }),
}); });
const result = await shortenUrl(env as Parameters<typeof shortenUrl>[0], { const result = await shortenUrl(
targetUrl: 'https://example.com', env as Parameters<typeof shortenUrl>[0],
}); {
targetUrl: 'https://example.com',
},
);
expect(result.shortUrl).toBe('https://mifi.me/abc'); expect(result.shortUrl).toBe('https://mifi.me/abc');
expect(fetch).toHaveBeenCalledWith( expect(fetch).toHaveBeenCalledWith(
'http://kutt:3000/api/v2/links', 'http://kutt:3000/api/v2/links',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, headers: {
'Content-Type': 'application/json',
'X-API-Key': 'test-key',
},
body: JSON.stringify({ target: 'https://example.com' }), body: JSON.stringify({ target: 'https://example.com' }),
}), }),
); );
@@ -46,16 +52,24 @@ describe('shortenUrl', () => {
expect(fetch).toHaveBeenCalledWith( expect(fetch).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
body: JSON.stringify({ target: 'https://example.com', customurl: 'myslug' }), body: JSON.stringify({
target: 'https://example.com',
customurl: 'myslug',
}),
}), }),
); );
}); });
it('throws when KUTT_API_KEY is missing', async () => { it('throws when KUTT_API_KEY is missing', async () => {
await expect( await expect(
shortenUrl({ ...env, KUTT_API_KEY: undefined } as Parameters<typeof shortenUrl>[0], { shortenUrl(
targetUrl: 'https://example.com', { ...env, KUTT_API_KEY: undefined } as Parameters<
}), typeof shortenUrl
>[0],
{
targetUrl: 'https://example.com',
},
),
).rejects.toThrow('KUTT_API_KEY'); ).rejects.toThrow('KUTT_API_KEY');
}); });
@@ -66,7 +80,45 @@ describe('shortenUrl', () => {
text: () => Promise.resolve('Bad request'), text: () => Promise.resolve('Bad request'),
}); });
await expect( await expect(
shortenUrl(env as Parameters<typeof shortenUrl>[0], { targetUrl: 'https://example.com' }), shortenUrl(env as Parameters<typeof shortenUrl>[0], {
targetUrl: 'https://example.com',
}),
).rejects.toThrow(/400/); ).rejects.toThrow(/400/);
}); });
it('uses id when link is missing and prepends SHORT_DOMAIN', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 'xyz99' }),
});
const result = await shortenUrl(
env as Parameters<typeof shortenUrl>[0],
{ targetUrl: 'https://example.com' },
);
expect(result.shortUrl).toBe('https://mifi.me/xyz99');
});
it('prepends SHORT_DOMAIN when link is relative', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ link: '/abc' }),
});
const result = await shortenUrl(
env as Parameters<typeof shortenUrl>[0],
{ targetUrl: 'https://example.com' },
);
expect(result.shortUrl).toBe('https://mifi.me/abc');
});
it('throws when Kutt returns no link or id', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
await expect(
shortenUrl(env as Parameters<typeof shortenUrl>[0], {
targetUrl: 'https://example.com',
}),
).rejects.toThrow('Kutt API did not return a short URL');
});
}); });

View File

@@ -9,7 +9,10 @@ export interface ShortenResult {
shortUrl: string; shortUrl: string;
} }
export async function shortenUrl(env: Env, body: ShortenBody): Promise<ShortenResult> { export async function shortenUrl(
env: Env,
body: ShortenBody,
): Promise<ShortenResult> {
if (!env.KUTT_API_KEY) { if (!env.KUTT_API_KEY) {
throw new Error('KUTT_API_KEY is not configured'); throw new Error('KUTT_API_KEY is not configured');
} }
@@ -33,11 +36,15 @@ export async function shortenUrl(env: Env, body: ShortenBody): Promise<ShortenRe
} }
const data = (await res.json()) as { link?: string; id?: string }; const data = (await res.json()) as { link?: string; id?: string };
const link = data.link ?? (data.id ? `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${data.id}` : null); const link =
data.link ??
(data.id ? `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${data.id}` : null);
if (!link) { if (!link) {
throw new Error('Kutt API did not return a short URL'); throw new Error('Kutt API did not return a short URL');
} }
const shortUrl = link.startsWith('http') ? link : `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${link}`; const shortUrl = link.startsWith('http')
? link
: `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${link.replace(/^\//, '')}`;
return { shortUrl }; return { shortUrl };
} }

12
qr-api/src/upload.test.ts Normal file
View File

@@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest';
import { createMulter } from './upload.js';
describe('createMulter', () => {
it('returns multer instance with single()', () => {
const upload = createMulter({
UPLOADS_PATH: '/tmp',
} as Parameters<typeof createMulter>[0]);
expect(upload.single).toBeDefined();
expect(typeof upload.single).toBe('function');
});
});

View File

@@ -3,7 +3,13 @@ import path from 'path';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import type { Env } from './env.js'; import type { Env } from './env.js';
const IMAGE_MIME = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; const IMAGE_MIME = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
export function createMulter(env: Env) { export function createMulter(env: Env) {
@@ -20,7 +26,11 @@ export function createMulter(env: Env) {
if (IMAGE_MIME.includes(file.mimetype)) { if (IMAGE_MIME.includes(file.mimetype)) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Only image files (jpeg, png, gif, webp) are allowed')); cb(
new Error(
'Only image files (jpeg, png, gif, webp) are allowed',
),
);
} }
}, },
}); });

View File

@@ -4,5 +4,17 @@ export default defineConfig({
test: { test: {
globals: false, globals: false,
environment: 'node', environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/index.ts'],
thresholds: {
lines: 90,
functions: 100,
branches: 72,
statements: 90,
},
},
}, },
}); });

View File

@@ -1,10 +0,0 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: ['next/core-web-vitals', 'prettier'],
plugins: ['@typescript-eslint'],
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
};

18
qr-web/eslint.config.cjs Normal file
View File

@@ -0,0 +1,18 @@
'use strict';
const nextConfig = require('eslint-config-next/core-web-vitals');
const prettierConfig = require('eslint-config-prettier');
module.exports = [
...nextConfig,
prettierConfig,
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
},
},
];

View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" /> import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -3,11 +3,15 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev",
"build": "next build", "build": "next build",
"dev": "next dev",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"start": "next start", "start": "next start",
"lint": "next lint",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
@@ -15,11 +19,11 @@
"@tabler/icons-react": "^3.23.0", "@tabler/icons-react": "^3.23.0",
"@mantine/dropzone": "^7.14.0", "@mantine/dropzone": "^7.14.0",
"@mantine/hooks": "^7.14.0", "@mantine/hooks": "^7.14.0",
"next": "^15.0.3", "next": "^16.1.6",
"pdf-lib": "^1.4.2", "pdf-lib": "^1.4.2",
"qr-code-styling": "^1.9.2", "qr-code-styling": "^1.9.2",
"react": "^19.0.0", "react": "^19.2.4",
"react-dom": "^19.0.0" "react-dom": "^19.2.4"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react": "^16.0.1", "@testing-library/react": "^16.0.1",
@@ -29,7 +33,7 @@
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-config-next": "^15.0.3", "eslint-config-next": "^16.1.6",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
@@ -37,6 +41,7 @@
"postcss-preset-env": "^10.0.0", "postcss-preset-env": "^10.0.0",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vitest": "^2.1.6", "vitest": "^2.1.6",
"@vitest/coverage-v8": "^2.1.6",
"@vitejs/plugin-react": "^4.3.4" "@vitejs/plugin-react": "^4.3.4"
}, },
"engines": { "engines": {

View File

@@ -6,10 +6,15 @@ export async function GET(
) { ) {
const { id } = await params; const { id } = await params;
try { try {
const res = await fetch(`${QR_API_URL}/folders/${id}`, { cache: 'no-store' }); const res = await fetch(`${QR_API_URL}/folders/${id}`, {
cache: 'no-store',
});
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
return Response.json({ error: data?.error ?? 'Not found' }, { status: res.status }); return Response.json(
{ error: data?.error ?? 'Not found' },
{ status: res.status },
);
} }
return Response.json(data); return Response.json(data);
} catch (e) { } catch (e) {
@@ -31,7 +36,10 @@ export async function PUT(
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status }); return Response.json(
{ error: data?.error ?? 'Failed' },
{ status: res.status },
);
} }
return Response.json(data); return Response.json(data);
} catch (e) { } catch (e) {
@@ -45,12 +53,17 @@ export async function DELETE(
) { ) {
const { id } = await params; const { id } = await params;
try { try {
const res = await fetch(`${QR_API_URL}/folders/${id}`, { method: 'DELETE' }); const res = await fetch(`${QR_API_URL}/folders/${id}`, {
method: 'DELETE',
});
if (res.status === 204) { if (res.status === 204) {
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
const data = await res.json(); const data = await res.json();
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status }); return Response.json(
{ error: data?.error ?? 'Failed' },
{ status: res.status },
);
} catch (e) { } catch (e) {
return Response.json({ error: String(e) }, { status: 502 }); return Response.json({ error: String(e) }, { status: 502 });
} }

View File

@@ -5,7 +5,10 @@ export async function GET() {
const res = await fetch(`${QR_API_URL}/folders`, { cache: 'no-store' }); const res = await fetch(`${QR_API_URL}/folders`, { cache: 'no-store' });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status }); return Response.json(
{ error: data?.error ?? 'Failed' },
{ status: res.status },
);
} }
return Response.json(Array.isArray(data) ? data : data); return Response.json(Array.isArray(data) ? data : data);
} catch (e) { } catch (e) {
@@ -23,7 +26,10 @@ export async function POST(request: Request) {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status }); return Response.json(
{ error: data?.error ?? 'Failed' },
{ status: res.status },
);
} }
return Response.json(data); return Response.json(data);
} catch (e) { } catch (e) {

View File

@@ -6,13 +6,21 @@ export async function GET(
) { ) {
const { id } = await params; const { id } = await params;
try { try {
const res = await fetch(`${QR_API_URL}/projects/${id}`, { cache: 'no-store' }); const res = await fetch(`${QR_API_URL}/projects/${id}`, {
cache: 'no-store',
});
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
return Response.json({ error: data?.error ?? 'Not found' }, { status: res.status }); return Response.json(
{ error: data?.error ?? 'Not found' },
{ status: res.status },
);
} }
if (data?.logoUrl) { if (data?.logoUrl) {
data.logoUrl = data.logoUrl.replace(/^\/uploads\//, '/api/uploads/'); data.logoUrl = data.logoUrl.replace(
/^\/uploads\//,
'/api/uploads/',
);
} }
return Response.json(data); return Response.json(data);
} catch (e) { } catch (e) {
@@ -34,10 +42,16 @@ export async function PUT(
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status }); return Response.json(
{ error: data?.error ?? 'Failed' },
{ status: res.status },
);
} }
if (data?.logoUrl) { if (data?.logoUrl) {
data.logoUrl = data.logoUrl.replace(/^\/uploads\//, '/api/uploads/'); data.logoUrl = data.logoUrl.replace(
/^\/uploads\//,
'/api/uploads/',
);
} }
return Response.json(data); return Response.json(data);
} catch (e) { } catch (e) {
@@ -51,12 +65,17 @@ export async function DELETE(
) { ) {
const { id } = await params; const { id } = await params;
try { try {
const res = await fetch(`${QR_API_URL}/projects/${id}`, { method: 'DELETE' }); const res = await fetch(`${QR_API_URL}/projects/${id}`, {
method: 'DELETE',
});
if (res.status === 204) { if (res.status === 204) {
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
const data = await res.json(); const data = await res.json();
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status }); return Response.json(
{ error: data?.error ?? 'Failed' },
{ status: res.status },
);
} catch (e) { } catch (e) {
return Response.json({ error: String(e) }, { status: 502 }); return Response.json({ error: String(e) }, { status: 502 });
} }

View File

@@ -9,10 +9,15 @@ function rewriteLogoUrl(items: Array<{ logoUrl?: string | null }>) {
export async function GET() { export async function GET() {
try { try {
const res = await fetch(`${QR_API_URL}/projects`, { cache: 'no-store' }); const res = await fetch(`${QR_API_URL}/projects`, {
cache: 'no-store',
});
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status }); return Response.json(
{ error: data?.error ?? 'Failed' },
{ status: res.status },
);
} }
return Response.json(Array.isArray(data) ? rewriteLogoUrl(data) : data); return Response.json(Array.isArray(data) ? rewriteLogoUrl(data) : data);
} catch (e) { } catch (e) {
@@ -30,7 +35,10 @@ export async function POST(request: Request) {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status }); return Response.json(
{ error: data?.error ?? 'Failed' },
{ status: res.status },
);
} }
return Response.json(data); return Response.json(data);
} catch (e) { } catch (e) {

View File

@@ -10,7 +10,10 @@ export async function POST(request: Request) {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
return Response.json({ error: data?.error ?? 'Shorten failed' }, { status: res.status }); return Response.json(
{ error: data?.error ?? 'Shorten failed' },
{ status: res.status },
);
} }
return Response.json(data); return Response.json(data);
} catch (e) { } catch (e) {

View File

@@ -8,5 +8,8 @@ body {
margin: 0; margin: 0;
background: var(--app-bg); background: var(--app-bg);
color: #e6edf3; color: #e6edf3;
font-family: system-ui, -apple-system, sans-serif; font-family:
system-ui,
-apple-system,
sans-serif;
} }

View File

@@ -6,11 +6,7 @@ import { Sidebar } from '@/components/Sidebar';
import { ProjectsProvider, useProjects } from '@/contexts/ProjectsContext'; import { ProjectsProvider, useProjects } from '@/contexts/ProjectsContext';
import classes from './layout.module.css'; import classes from './layout.module.css';
function ProjectsLayoutInner({ function ProjectsLayoutInner({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const { refetch } = useProjects(); const { refetch } = useProjects();
useEffect(() => { useEffect(() => {

View File

@@ -20,14 +20,25 @@ import {
Button, Button,
ActionIcon, ActionIcon,
} from '@mantine/core'; } from '@mantine/core';
import { IconLink, IconFileText, IconMail, IconPhone, IconTrash } from '@tabler/icons-react'; import {
IconLink,
IconFileText,
IconMail,
IconPhone,
IconTrash,
} from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone'; import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
import { useDebouncedCallback } from '@mantine/hooks'; import { useDebouncedCallback } from '@mantine/hooks';
import { QrPreview } from './QrPreview'; import { QrPreview } from './QrPreview';
import { ExportPanel } from './ExportPanel'; import { ExportPanel } from './ExportPanel';
import { useProjects } from '@/contexts/ProjectsContext'; import { useProjects } from '@/contexts/ProjectsContext';
import type { Project, RecipeOptions, ContentType, QrGradient } from '@/types/project'; import type {
Project,
RecipeOptions,
ContentType,
QrGradient,
} from '@/types/project';
import { makeGradient } from '@/lib/qrStylingOptions'; import { makeGradient } from '@/lib/qrStylingOptions';
import classes from './Editor.module.css'; import classes from './Editor.module.css';
@@ -49,7 +60,11 @@ const CONTENT_TYPES: Array<{
placeholder: 'https://example.com', placeholder: 'https://example.com',
inputLabel: 'Website address', inputLabel: 'Website address',
validate: (v) => validate: (v) =>
!v.trim() ? 'Enter a URL' : /^https?:\/\/.+/i.test(v.trim()) ? null : 'URL must start with http:// or https://', !v.trim()
? 'Enter a URL'
: /^https?:\/\/.+/i.test(v.trim())
? null
: 'URL must start with http:// or https://',
}, },
{ {
value: 'text', value: 'text',
@@ -76,7 +91,9 @@ const CONTENT_TYPES: Array<{
validate: (v) => { validate: (v) => {
if (!v.trim()) return 'Enter an email address'; if (!v.trim()) return 'Enter an email address';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(v.trim()) ? null : 'Enter a valid email address'; return emailRegex.test(v.trim())
? null
: 'Enter a valid email address';
}, },
}, },
{ {
@@ -92,13 +109,16 @@ const CONTENT_TYPES: Array<{
validate: (v) => { validate: (v) => {
if (!v.trim()) return 'Enter a phone number'; if (!v.trim()) return 'Enter a phone number';
const digits = v.replace(/\D/g, ''); const digits = v.replace(/\D/g, '');
return digits.length >= 7 && digits.length <= 15 ? null : 'Enter a valid phone number (715 digits)'; return digits.length >= 7 && digits.length <= 15
? null
: 'Enter a valid phone number (715 digits)';
}, },
}, },
]; ];
function inferContentType(content: string, current?: ContentType): ContentType { function inferContentType(content: string, current?: ContentType): ContentType {
if (current && CONTENT_TYPES.some((t) => t.value === current)) return current; if (current && CONTENT_TYPES.some((t) => t.value === current))
return current;
const t = content.trim(); const t = content.trim();
if (/^https?:\/\//i.test(t)) return 'url'; if (/^https?:\/\//i.test(t)) return 'url';
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) return 'email'; if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) return 'email';
@@ -229,11 +249,16 @@ export function Editor({ id }: EditorProps) {
shortenEnabled: true, shortenEnabled: true,
recipeJson: (() => { recipeJson: (() => {
try { try {
const recipe = JSON.parse(project.recipeJson || '{}') as RecipeOptions; const recipe = JSON.parse(
project.recipeJson || '{}',
) as RecipeOptions;
recipe.data = data.shortUrl; recipe.data = data.shortUrl;
return JSON.stringify(recipe); return JSON.stringify(recipe);
} catch { } catch {
return JSON.stringify({ ...project, data: data.shortUrl }); return JSON.stringify({
...project,
data: data.shortUrl,
});
} }
})(), })(),
}); });
@@ -271,10 +296,17 @@ export function Editor({ id }: EditorProps) {
if (!project) return; if (!project) return;
setContentTouched(false); setContentTouched(false);
try { try {
const r = JSON.parse(project.recipeJson || '{}') as RecipeOptions; const r = JSON.parse(
project.recipeJson || '{}',
) as RecipeOptions;
r.contentType = type; r.contentType = type;
const patch: Partial<Project> = { recipeJson: JSON.stringify(r) }; const patch: Partial<Project> = {
if (type !== 'url' && (project.shortenEnabled || project.shortUrl)) { recipeJson: JSON.stringify(r),
};
if (
type !== 'url' &&
(project.shortenEnabled || project.shortUrl)
) {
patch.shortenEnabled = false; patch.shortenEnabled = false;
patch.shortUrl = null; patch.shortUrl = null;
r.data = (project.originalUrl ?? '') || undefined; r.data = (project.originalUrl ?? '') || undefined;
@@ -293,15 +325,23 @@ export function Editor({ id }: EditorProps) {
const content = project.originalUrl ?? ''; const content = project.originalUrl ?? '';
let recipe: RecipeOptions = {}; let recipe: RecipeOptions = {};
try { try {
recipe = JSON.parse(project.recipeJson || '{}') as RecipeOptions; recipe = JSON.parse(
project.recipeJson || '{}',
) as RecipeOptions;
} catch { } catch {
recipe = {}; recipe = {};
} }
const contentType = inferContentType(content, recipe.contentType); const contentType = inferContentType(content, recipe.contentType);
try { try {
const r = JSON.parse(project.recipeJson || '{}') as RecipeOptions; const r = JSON.parse(
project.recipeJson || '{}',
) as RecipeOptions;
r.contentType = contentType; r.contentType = contentType;
if (contentType === 'url' && project.shortenEnabled && project.shortUrl) { if (
contentType === 'url' &&
project.shortenEnabled &&
project.shortUrl
) {
r.data = project.shortUrl; r.data = project.shortUrl;
} else { } else {
r.data = value || undefined; r.data = value || undefined;
@@ -310,7 +350,10 @@ export function Editor({ id }: EditorProps) {
originalUrl: value, originalUrl: value,
recipeJson: JSON.stringify(r), recipeJson: JSON.stringify(r),
}; };
if (contentType !== 'url' && (project.shortenEnabled || project.shortUrl)) { if (
contentType !== 'url' &&
(project.shortenEnabled || project.shortUrl)
) {
patch.shortenEnabled = false; patch.shortenEnabled = false;
patch.shortUrl = null; patch.shortUrl = null;
r.data = value || undefined; r.data = value || undefined;
@@ -352,7 +395,8 @@ export function Editor({ id }: EditorProps) {
} }
const content = project.originalUrl ?? ''; const content = project.originalUrl ?? '';
const contentType = inferContentType(content, recipe.contentType); const contentType = inferContentType(content, recipe.contentType);
const typeConfig = CONTENT_TYPES.find((t) => t.value === contentType) ?? CONTENT_TYPES[0]; const typeConfig =
CONTENT_TYPES.find((t) => t.value === contentType) ?? CONTENT_TYPES[0];
const contentError = contentTouched ? typeConfig.validate(content) : null; const contentError = contentTouched ? typeConfig.validate(content) : null;
const isUrl = contentType === 'url'; const isUrl = contentType === 'url';
const qrData = const qrData =
@@ -366,7 +410,11 @@ export function Editor({ id }: EditorProps) {
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{saving ? 'Saving…' : lastSaved ? `Saved ${lastSaved.toLocaleTimeString()}` : ''} {saving
? 'Saving…'
: lastSaved
? `Saved ${lastSaved.toLocaleTimeString()}`
: ''}
</Text> </Text>
<ActionIcon <ActionIcon
size="sm" size="sm"
@@ -382,7 +430,9 @@ export function Editor({ id }: EditorProps) {
label="Project name" label="Project name"
placeholder="Untitled QR" placeholder="Untitled QR"
value={project.name} value={project.name}
onChange={(e) => updateProject({ name: e.target.value })} onChange={(e) =>
updateProject({ name: e.target.value })
}
/> />
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
Content type Content type
@@ -390,7 +440,10 @@ export function Editor({ id }: EditorProps) {
<SegmentedControl <SegmentedControl
value={contentType} value={contentType}
onChange={(v) => setContentType(v as ContentType)} onChange={(v) => setContentType(v as ContentType)}
data={CONTENT_TYPES.map((t) => ({ value: t.value, label: t.label }))} data={CONTENT_TYPES.map((t) => ({
value: t.value,
label: t.label,
}))}
fullWidth fullWidth
/> />
<TextInput <TextInput
@@ -418,8 +471,11 @@ export function Editor({ id }: EditorProps) {
checked={project.shortenEnabled} checked={project.shortenEnabled}
onChange={(e) => { onChange={(e) => {
const checked = e.currentTarget.checked; const checked = e.currentTarget.checked;
updateProject({ shortenEnabled: checked }); updateProject({
if (checked && project.originalUrl) handleShorten(); shortenEnabled: checked,
});
if (checked && project.originalUrl)
handleShorten();
}} }}
/> />
</Group> </Group>
@@ -451,24 +507,33 @@ export function Editor({ id }: EditorProps) {
value={recipe.imageOptions?.imageSize ?? 0.4} value={recipe.imageOptions?.imageSize ?? 0.4}
onChange={(n) => { onChange={(n) => {
const r = { ...recipe }; const r = { ...recipe };
const v = typeof n === 'string' ? parseFloat(n) : n; const v =
typeof n === 'string' ? parseFloat(n) : n;
r.imageOptions = { r.imageOptions = {
...r.imageOptions, ...r.imageOptions,
imageSize: Number.isFinite(v) ? Math.max(0.1, Math.min(0.6, v)) : 0.4, imageSize: Number.isFinite(v)
? Math.max(0.1, Math.min(0.6, v))
: 0.4,
}; };
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
<Switch <Switch
label="Hide dots behind logo" label="Hide dots behind logo"
checked={recipe.imageOptions?.hideBackgroundDots ?? true} checked={
recipe.imageOptions?.hideBackgroundDots ?? true
}
onChange={(e) => { onChange={(e) => {
const r = { ...recipe }; const r = { ...recipe };
r.imageOptions = { r.imageOptions = {
...r.imageOptions, ...r.imageOptions,
hideBackgroundDots: e.currentTarget.checked, hideBackgroundDots: e.currentTarget.checked,
}; };
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
</Group> </Group>
@@ -477,7 +542,9 @@ export function Editor({ id }: EditorProps) {
Foreground Foreground
</Text> </Text>
<SegmentedControl <SegmentedControl
value={recipe.dotsOptions?.gradient ? 'gradient' : 'solid'} value={
recipe.dotsOptions?.gradient ? 'gradient' : 'solid'
}
onChange={(v) => { onChange={(v) => {
const r = { ...recipe }; const r = { ...recipe };
if (v === 'gradient') { if (v === 'gradient') {
@@ -487,13 +554,42 @@ export function Editor({ id }: EditorProps) {
'#444444', '#444444',
0, 0,
); );
r.dotsOptions = { ...r.dotsOptions, gradient: g, color: undefined }; r.dotsOptions = {
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g, color: undefined }; ...r.dotsOptions,
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g, color: undefined }; gradient: g,
color: undefined,
};
r.cornersSquareOptions = {
...r.cornersSquareOptions,
gradient: g,
color: undefined,
};
r.cornersDotOptions = {
...r.cornersDotOptions,
gradient: g,
color: undefined,
};
} else { } else {
r.dotsOptions = { ...r.dotsOptions, gradient: undefined, color: recipe.dotsOptions?.color ?? '#000000' }; r.dotsOptions = {
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: undefined, color: recipe.cornersSquareOptions?.color ?? '#000000' }; ...r.dotsOptions,
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: undefined, color: recipe.cornersSquareOptions?.color ?? '#000000' }; gradient: undefined,
color:
recipe.dotsOptions?.color ?? '#000000',
};
r.cornersSquareOptions = {
...r.cornersSquareOptions,
gradient: undefined,
color:
recipe.cornersSquareOptions?.color ??
'#000000',
};
r.cornersDotOptions = {
...r.cornersDotOptions,
gradient: undefined,
color:
recipe.cornersSquareOptions?.color ??
'#000000',
};
} }
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({ recipeJson: JSON.stringify(r) });
}} }}
@@ -517,61 +613,147 @@ export function Editor({ id }: EditorProps) {
const r = { ...recipe }; const r = { ...recipe };
const g: QrGradient = { const g: QrGradient = {
...recipe.dotsOptions!.gradient!, ...recipe.dotsOptions!.gradient!,
type: (v as 'linear' | 'radial') ?? 'linear', type:
(v as 'linear' | 'radial') ??
'linear',
}; };
r.dotsOptions = { ...r.dotsOptions, gradient: g }; r.dotsOptions = {
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g }; ...r.dotsOptions,
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g }; gradient: g,
updateProject({ recipeJson: JSON.stringify(r) }); };
r.cornersSquareOptions = {
...r.cornersSquareOptions,
gradient: g,
};
r.cornersDotOptions = {
...r.cornersDotOptions,
gradient: g,
};
updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
<NumberInput <NumberInput
label="Rotation (°)" label="Rotation (°)"
min={0} min={0}
max={360} max={360}
value={recipe.dotsOptions.gradient.rotation ?? 0} value={
recipe.dotsOptions.gradient.rotation ??
0
}
onChange={(n) => { onChange={(n) => {
const r = { ...recipe }; const r = { ...recipe };
const g: QrGradient = { const g: QrGradient = {
...recipe.dotsOptions!.gradient!, ...recipe.dotsOptions!.gradient!,
rotation: typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0, rotation:
typeof n === 'string'
? parseInt(n, 10) || 0
: (n ?? 0),
}; };
r.dotsOptions = { ...r.dotsOptions, gradient: g }; r.dotsOptions = {
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g }; ...r.dotsOptions,
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g }; gradient: g,
updateProject({ recipeJson: JSON.stringify(r) }); };
r.cornersSquareOptions = {
...r.cornersSquareOptions,
gradient: g,
};
r.cornersDotOptions = {
...r.cornersDotOptions,
gradient: g,
};
updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
</Group> </Group>
<Group grow> <Group grow>
<ColorInput <ColorInput
label="Start color" label="Start color"
value={recipe.dotsOptions.gradient.colorStops[0]?.color ?? '#000000'} value={
recipe.dotsOptions.gradient
.colorStops[0]?.color ?? '#000000'
}
onChange={(c) => { onChange={(c) => {
const r = { ...recipe }; const r = { ...recipe };
const stops = [...(recipe.dotsOptions!.gradient!.colorStops || [])]; const stops = [
if (stops[0]) stops[0] = { ...stops[0], color: c }; ...(recipe.dotsOptions!.gradient!
else stops.unshift({ offset: 0, color: c }); .colorStops || []),
const g: QrGradient = { ...recipe.dotsOptions!.gradient!, colorStops: stops }; ];
r.dotsOptions = { ...r.dotsOptions, gradient: g }; if (stops[0])
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g }; stops[0] = {
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g }; ...stops[0],
updateProject({ recipeJson: JSON.stringify(r) }); color: c,
};
else
stops.unshift({
offset: 0,
color: c,
});
const g: QrGradient = {
...recipe.dotsOptions!.gradient!,
colorStops: stops,
};
r.dotsOptions = {
...r.dotsOptions,
gradient: g,
};
r.cornersSquareOptions = {
...r.cornersSquareOptions,
gradient: g,
};
r.cornersDotOptions = {
...r.cornersDotOptions,
gradient: g,
};
updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
<ColorInput <ColorInput
label="End color" label="End color"
value={recipe.dotsOptions.gradient.colorStops[1]?.color ?? recipe.dotsOptions.gradient.colorStops[0]?.color ?? '#444444'} value={
recipe.dotsOptions.gradient
.colorStops[1]?.color ??
recipe.dotsOptions.gradient
.colorStops[0]?.color ??
'#444444'
}
onChange={(c) => { onChange={(c) => {
const r = { ...recipe }; const r = { ...recipe };
const stops = [...(recipe.dotsOptions!.gradient!.colorStops || [])]; const stops = [
if (stops[1]) stops[1] = { ...stops[1], color: c }; ...(recipe.dotsOptions!.gradient!
else stops.push({ offset: 1, color: c }); .colorStops || []),
const g: QrGradient = { ...recipe.dotsOptions!.gradient!, colorStops: stops }; ];
r.dotsOptions = { ...r.dotsOptions, gradient: g }; if (stops[1])
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g }; stops[1] = {
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g }; ...stops[1],
updateProject({ recipeJson: JSON.stringify(r) }); color: c,
};
else
stops.push({ offset: 1, color: c });
const g: QrGradient = {
...recipe.dotsOptions!.gradient!,
colorStops: stops,
};
r.dotsOptions = {
...r.dotsOptions,
gradient: g,
};
r.cornersSquareOptions = {
...r.cornersSquareOptions,
gradient: g,
};
r.cornersDotOptions = {
...r.cornersDotOptions,
gradient: g,
};
updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
</Group> </Group>
@@ -583,9 +765,17 @@ export function Editor({ id }: EditorProps) {
onChange={(c) => { onChange={(c) => {
const r = { ...recipe }; const r = { ...recipe };
r.dotsOptions = { ...r.dotsOptions, color: c }; r.dotsOptions = { ...r.dotsOptions, color: c };
r.cornersSquareOptions = { ...r.cornersSquareOptions, color: c }; r.cornersSquareOptions = {
r.cornersDotOptions = { ...r.cornersDotOptions, color: c }; ...r.cornersSquareOptions,
updateProject({ recipeJson: JSON.stringify(r) }); color: c,
};
r.cornersDotOptions = {
...r.cornersDotOptions,
color: c,
};
updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
)} )}
@@ -593,7 +783,11 @@ export function Editor({ id }: EditorProps) {
Background Background
</Text> </Text>
<SegmentedControl <SegmentedControl
value={recipe.backgroundOptions?.gradient ? 'gradient' : 'solid'} value={
recipe.backgroundOptions?.gradient
? 'gradient'
: 'solid'
}
onChange={(v) => { onChange={(v) => {
const r = { ...recipe }; const r = { ...recipe };
if (v === 'gradient') { if (v === 'gradient') {
@@ -601,7 +795,8 @@ export function Editor({ id }: EditorProps) {
...r.backgroundOptions, ...r.backgroundOptions,
gradient: makeGradient( gradient: makeGradient(
'linear', 'linear',
recipe.backgroundOptions?.color ?? '#ffffff', recipe.backgroundOptions?.color ??
'#ffffff',
'#e0e0e0', '#e0e0e0',
0, 0,
), ),
@@ -611,7 +806,9 @@ export function Editor({ id }: EditorProps) {
r.backgroundOptions = { r.backgroundOptions = {
...r.backgroundOptions, ...r.backgroundOptions,
gradient: undefined, gradient: undefined,
color: recipe.backgroundOptions?.color ?? '#ffffff', color:
recipe.backgroundOptions?.color ??
'#ffffff',
}; };
} }
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({ recipeJson: JSON.stringify(r) });
@@ -631,66 +828,123 @@ export function Editor({ id }: EditorProps) {
{ value: 'linear', label: 'Linear' }, { value: 'linear', label: 'Linear' },
{ value: 'radial', label: 'Radial' }, { value: 'radial', label: 'Radial' },
]} ]}
value={recipe.backgroundOptions.gradient.type} value={
recipe.backgroundOptions.gradient.type
}
onChange={(v) => { onChange={(v) => {
const r = { ...recipe }; const r = { ...recipe };
r.backgroundOptions = { r.backgroundOptions = {
...r.backgroundOptions, ...r.backgroundOptions,
gradient: { gradient: {
...recipe.backgroundOptions!.gradient!, ...recipe.backgroundOptions!
type: (v as 'linear' | 'radial') ?? 'linear', .gradient!,
type:
(v as
| 'linear'
| 'radial') ?? 'linear',
}, },
}; };
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
<NumberInput <NumberInput
label="Rotation (°)" label="Rotation (°)"
min={0} min={0}
max={360} max={360}
value={recipe.backgroundOptions.gradient.rotation ?? 0} value={
recipe.backgroundOptions.gradient
.rotation ?? 0
}
onChange={(n) => { onChange={(n) => {
const r = { ...recipe }; const r = { ...recipe };
r.backgroundOptions = { r.backgroundOptions = {
...r.backgroundOptions, ...r.backgroundOptions,
gradient: { gradient: {
...recipe.backgroundOptions!.gradient!, ...recipe.backgroundOptions!
rotation: typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0, .gradient!,
rotation:
typeof n === 'string'
? parseInt(n, 10) || 0
: (n ?? 0),
}, },
}; };
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
</Group> </Group>
<Group grow> <Group grow>
<ColorInput <ColorInput
label="Start color" label="Start color"
value={recipe.backgroundOptions.gradient.colorStops[0]?.color ?? '#ffffff'} value={
recipe.backgroundOptions.gradient
.colorStops[0]?.color ?? '#ffffff'
}
onChange={(c) => { onChange={(c) => {
const r = { ...recipe }; const r = { ...recipe };
const stops = [...(recipe.backgroundOptions!.gradient!.colorStops || [])]; const stops = [
if (stops[0]) stops[0] = { ...stops[0], color: c }; ...(recipe.backgroundOptions!
else stops.unshift({ offset: 0, color: c }); .gradient!.colorStops || []),
];
if (stops[0])
stops[0] = {
...stops[0],
color: c,
};
else
stops.unshift({
offset: 0,
color: c,
});
r.backgroundOptions = { r.backgroundOptions = {
...r.backgroundOptions, ...r.backgroundOptions,
gradient: { ...recipe.backgroundOptions!.gradient!, colorStops: stops }, gradient: {
...recipe.backgroundOptions!
.gradient!,
colorStops: stops,
},
}; };
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
<ColorInput <ColorInput
label="End color" label="End color"
value={recipe.backgroundOptions.gradient.colorStops[1]?.color ?? recipe.backgroundOptions.gradient.colorStops[0]?.color ?? '#e0e0e0'} value={
recipe.backgroundOptions.gradient
.colorStops[1]?.color ??
recipe.backgroundOptions.gradient
.colorStops[0]?.color ??
'#e0e0e0'
}
onChange={(c) => { onChange={(c) => {
const r = { ...recipe }; const r = { ...recipe };
const stops = [...(recipe.backgroundOptions!.gradient!.colorStops || [])]; const stops = [
if (stops[1]) stops[1] = { ...stops[1], color: c }; ...(recipe.backgroundOptions!
else stops.push({ offset: 1, color: c }); .gradient!.colorStops || []),
];
if (stops[1])
stops[1] = {
...stops[1],
color: c,
};
else
stops.push({ offset: 1, color: c });
r.backgroundOptions = { r.backgroundOptions = {
...r.backgroundOptions, ...r.backgroundOptions,
gradient: { ...recipe.backgroundOptions!.gradient!, colorStops: stops }, gradient: {
...recipe.backgroundOptions!
.gradient!,
colorStops: stops,
},
}; };
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
</Group> </Group>
@@ -701,8 +955,13 @@ export function Editor({ id }: EditorProps) {
value={recipe.backgroundOptions?.color ?? '#ffffff'} value={recipe.backgroundOptions?.color ?? '#ffffff'}
onChange={(c) => { onChange={(c) => {
const r = { ...recipe }; const r = { ...recipe };
r.backgroundOptions = { ...r.backgroundOptions, color: c }; r.backgroundOptions = {
updateProject({ recipeJson: JSON.stringify(r) }); ...r.backgroundOptions,
color: c,
};
updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
)} )}
@@ -712,7 +971,10 @@ export function Editor({ id }: EditorProps) {
value={recipe.dotsOptions?.type ?? 'square'} value={recipe.dotsOptions?.type ?? 'square'}
onChange={(v) => { onChange={(v) => {
const r = { ...recipe }; const r = { ...recipe };
r.dotsOptions = { ...r.dotsOptions, type: v ?? 'square' }; r.dotsOptions = {
...r.dotsOptions,
type: v ?? 'square',
};
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({ recipeJson: JSON.stringify(r) });
}} }}
/> />
@@ -721,7 +983,10 @@ export function Editor({ id }: EditorProps) {
checked={recipe.dotsOptions?.roundSize ?? false} checked={recipe.dotsOptions?.roundSize ?? false}
onChange={(e) => { onChange={(e) => {
const r = { ...recipe }; const r = { ...recipe };
r.dotsOptions = { ...r.dotsOptions, roundSize: e.currentTarget.checked }; r.dotsOptions = {
...r.dotsOptions,
roundSize: e.currentTarget.checked,
};
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({ recipeJson: JSON.stringify(r) });
}} }}
/> />
@@ -731,17 +996,27 @@ export function Editor({ id }: EditorProps) {
value={recipe.cornersSquareOptions?.type ?? 'square'} value={recipe.cornersSquareOptions?.type ?? 'square'}
onChange={(v) => { onChange={(v) => {
const r = { ...recipe }; const r = { ...recipe };
r.cornersSquareOptions = { ...r.cornersSquareOptions, type: v ?? 'square' }; r.cornersSquareOptions = {
...r.cornersSquareOptions,
type: v ?? 'square',
};
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({ recipeJson: JSON.stringify(r) });
}} }}
/> />
<Select <Select
label="Corner dot style" label="Corner dot style"
data={CORNER_TYPES} data={CORNER_TYPES}
value={recipe.cornersDotOptions?.type ?? recipe.cornersSquareOptions?.type ?? 'square'} value={
recipe.cornersDotOptions?.type ??
recipe.cornersSquareOptions?.type ??
'square'
}
onChange={(v) => { onChange={(v) => {
const r = { ...recipe }; const r = { ...recipe };
r.cornersDotOptions = { ...r.cornersDotOptions, type: v ?? 'square' }; r.cornersDotOptions = {
...r.cornersDotOptions,
type: v ?? 'square',
};
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({ recipeJson: JSON.stringify(r) });
}} }}
/> />
@@ -766,8 +1041,13 @@ export function Editor({ id }: EditorProps) {
value={recipe.margin ?? 0} value={recipe.margin ?? 0}
onChange={(n) => { onChange={(n) => {
const r = { ...recipe }; const r = { ...recipe };
r.margin = typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0; r.margin =
updateProject({ recipeJson: JSON.stringify(r) }); typeof n === 'string'
? parseInt(n, 10) || 0
: (n ?? 0);
updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
<NumberInput <NumberInput
@@ -779,9 +1059,14 @@ export function Editor({ id }: EditorProps) {
const r = { ...recipe }; const r = { ...recipe };
r.backgroundOptions = { r.backgroundOptions = {
...r.backgroundOptions, ...r.backgroundOptions,
round: typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0, round:
typeof n === 'string'
? parseInt(n, 10) || 0
: (n ?? 0),
}; };
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({
recipeJson: JSON.stringify(r),
});
}} }}
/> />
</Group> </Group>
@@ -791,7 +1076,10 @@ export function Editor({ id }: EditorProps) {
value={recipe.qrOptions?.errorCorrectionLevel ?? 'M'} value={recipe.qrOptions?.errorCorrectionLevel ?? 'M'}
onChange={(v) => { onChange={(v) => {
const r = { ...recipe }; const r = { ...recipe };
r.qrOptions = { ...r.qrOptions, errorCorrectionLevel: v ?? 'M' }; r.qrOptions = {
...r.qrOptions,
errorCorrectionLevel: v ?? 'M',
};
updateProject({ recipeJson: JSON.stringify(r) }); updateProject({ recipeJson: JSON.stringify(r) });
}} }}
/> />
@@ -822,11 +1110,15 @@ export function Editor({ id }: EditorProps) {
centered centered
> >
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
This cannot be undone. The project &quot;{project.name || 'Untitled QR'}&quot; will be This cannot be undone. The project &quot;
permanently deleted. {project.name || 'Untitled QR'}&quot; will be permanently
deleted.
</Text> </Text>
<Group justify="flex-end" gap="xs"> <Group justify="flex-end" gap="xs">
<Button variant="subtle" onClick={() => setDeleteConfirmOpen(false)}> <Button
variant="subtle"
onClick={() => setDeleteConfirmOpen(false)}
>
Cancel Cancel
</Button> </Button>
<Button color="red" onClick={handleDeleteProject}> <Button color="red" onClick={handleDeleteProject}>
@@ -837,4 +1129,3 @@ export function Editor({ id }: EditorProps) {
</div> </div>
); );
} }

View File

@@ -16,7 +16,12 @@ interface ExportPanelProps {
projectName?: string; projectName?: string;
} }
export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelProps) { export function ExportPanel({
data,
recipe,
logoUrl,
projectName,
}: ExportPanelProps) {
const qrRef = useRef<QRCodeStyling | null>(null); const qrRef = useRef<QRCodeStyling | null>(null);
const getQrInstance = useCallback(() => { const getQrInstance = useCallback(() => {
@@ -27,10 +32,14 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
image: logoUrl || undefined, image: logoUrl || undefined,
}); });
if (qrRef.current) { if (qrRef.current) {
qrRef.current.update(opts as Parameters<QRCodeStyling['update']>[0]); qrRef.current.update(
opts as Parameters<QRCodeStyling['update']>[0],
);
return qrRef.current; return qrRef.current;
} }
const qr = new QRCodeStyling(opts as ConstructorParameters<typeof QRCodeStyling>[0]); const qr = new QRCodeStyling(
opts as ConstructorParameters<typeof QRCodeStyling>[0],
);
qrRef.current = qr; qrRef.current = qr;
return qr; return qr;
}, [data, recipe, logoUrl]); }, [data, recipe, logoUrl]);
@@ -48,7 +57,10 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `qr-${projectName || 'export'}.svg`.replace(/[^a-z0-9.-]/gi, '-'); a.download = `qr-${projectName || 'export'}.svg`.replace(
/[^a-z0-9.-]/gi,
'-',
);
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [getQrInstance, projectName]); }, [getQrInstance, projectName]);
@@ -61,7 +73,10 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `qr-${projectName || 'export'}.png`.replace(/[^a-z0-9.-]/gi, '-'); a.download = `qr-${projectName || 'export'}.png`.replace(
/[^a-z0-9.-]/gi,
'-',
);
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [getQrInstance, projectName]); }, [getQrInstance, projectName]);
@@ -92,10 +107,15 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
page.drawText(urlText, { x: 50, y: 60, size: 10 }); page.drawText(urlText, { x: 50, y: 60, size: 10 });
} }
const pdfBytes = await pdfDoc.save(); const pdfBytes = await pdfDoc.save();
const url = URL.createObjectURL(new Blob([pdfBytes as BlobPart], { type: 'application/pdf' })); const url = URL.createObjectURL(
new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }),
);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `qr-${projectName || 'export'}.pdf`.replace(/[^a-z0-9.-]/gi, '-'); a.download = `qr-${projectName || 'export'}.pdf`.replace(
/[^a-z0-9.-]/gi,
'-',
);
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [getQrInstance, data, projectName]); }, [getQrInstance, data, projectName]);
@@ -106,13 +126,28 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
Export Export
</Text> </Text>
<Group> <Group>
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handleSvg}> <Button
leftSection={<IconDownload size={16} />}
variant="light"
size="sm"
onClick={handleSvg}
>
SVG SVG
</Button> </Button>
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handlePng}> <Button
leftSection={<IconDownload size={16} />}
variant="light"
size="sm"
onClick={handlePng}
>
PNG PNG
</Button> </Button>
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handlePdf}> <Button
leftSection={<IconDownload size={16} />}
variant="light"
size="sm"
onClick={handlePdf}
>
PDF PDF
</Button> </Button>
</Group> </Group>

View File

@@ -12,7 +12,12 @@ interface QrPreviewProps {
size?: number; size?: number;
} }
export function QrPreview({ data, recipe, logoUrl, size = 256 }: QrPreviewProps) { export function QrPreview({
data,
recipe,
logoUrl,
size = 256,
}: QrPreviewProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const qrRef = useRef<QRCodeStyling | null>(null); const qrRef = useRef<QRCodeStyling | null>(null);

View File

@@ -56,7 +56,9 @@ export function Sidebar() {
deleteFolder, deleteFolder,
} = useProjects(); } = useProjects();
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(
() => new Set(),
);
const [dragOverId, setDragOverId] = useState<string | null>(null); const [dragOverId, setDragOverId] = useState<string | null>(null);
const [editingFolderId, setEditingFolderId] = useState<string | null>(null); const [editingFolderId, setEditingFolderId] = useState<string | null>(null);
const [editingName, setEditingName] = useState(''); const [editingName, setEditingName] = useState('');
@@ -74,10 +76,13 @@ export function Sidebar() {
}); });
}, []); }, []);
const handleDragStart = useCallback((e: React.DragEvent, projectId: string) => { const handleDragStart = useCallback(
e.dataTransfer.setData('application/x-project-id', projectId); (e: React.DragEvent, projectId: string) => {
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('application/x-project-id', projectId);
}, []); e.dataTransfer.effectAllowed = 'move';
},
[],
);
const handleDragOver = useCallback((e: React.DragEvent, zoneId: string) => { const handleDragOver = useCallback((e: React.DragEvent, zoneId: string) => {
e.preventDefault(); e.preventDefault();
@@ -93,15 +98,20 @@ export function Sidebar() {
(e: React.DragEvent, targetFolderId: string | null) => { (e: React.DragEvent, targetFolderId: string | null) => {
e.preventDefault(); e.preventDefault();
setDragOverId(null); setDragOverId(null);
const projectId = e.dataTransfer.getData('application/x-project-id'); const projectId = e.dataTransfer.getData(
'application/x-project-id',
);
if (!projectId) return; if (!projectId) return;
const fid = targetFolderId === UNCATEGORIZED_ID ? null : targetFolderId; const fid =
targetFolderId === UNCATEGORIZED_ID ? null : targetFolderId;
moveProjectToFolder(projectId, fid); moveProjectToFolder(projectId, fid);
}, },
[moveProjectToFolder], [moveProjectToFolder],
); );
const uncategorized = projects.filter((p) => !p.folderId || p.folderId === ''); const uncategorized = projects.filter(
(p) => !p.folderId || p.folderId === '',
);
const projectsByFolder = folders.map((f) => ({ const projectsByFolder = folders.map((f) => ({
folder: f, folder: f,
projects: projects.filter((p) => p.folderId === f.id), projects: projects.filter((p) => p.folderId === f.id),
@@ -191,7 +201,10 @@ export function Sidebar() {
leftSection={<IconFolderPlus size={16} />} leftSection={<IconFolderPlus size={16} />}
onClick={() => { onClick={() => {
createFolder().then((folder) => { createFolder().then((folder) => {
if (folder) setExpandedIds((prev) => new Set([...prev, folder.id])); if (folder)
setExpandedIds(
(prev) => new Set([...prev, folder.id]),
);
}); });
}} }}
> >
@@ -205,7 +218,13 @@ export function Sidebar() {
'Uncategorized', 'Uncategorized',
null, null,
<> <>
<Text size="xs" c="dimmed" fw={500} mb={4} className={classes.sectionLabel}> <Text
size="xs"
c="dimmed"
fw={500}
mb={4}
className={classes.sectionLabel}
>
Uncategorized Uncategorized
</Text> </Text>
<Stack gap={2}> <Stack gap={2}>
@@ -213,79 +232,95 @@ export function Sidebar() {
</Stack> </Stack>
</>, </>,
)} )}
{projectsByFolder.map(({ folder, projects: folderProjects }) => { {projectsByFolder.map(
const isExpanded = expandedIds.has(folder.id); ({ folder, projects: folderProjects }) => {
return ( const isExpanded = expandedIds.has(folder.id);
<Box key={folder.id}> return (
{renderDropZone( <Box key={folder.id}>
folder.id, {renderDropZone(
folder.name, folder.id,
folder.id, folder.name,
<> folder.id,
<Group <>
gap={4} <Group
className={classes.folderHeader} gap={4}
onClick={() => toggleFolder(folder.id)} className={classes.folderHeader}
> onClick={() =>
{isExpanded ? ( toggleFolder(folder.id)
<IconChevronDown size={14} /> }
) : ( >
<IconChevronRight size={14} /> {isExpanded ? (
)} <IconChevronDown size={14} />
{isExpanded ? ( ) : (
<IconFolderOpen size={16} /> <IconChevronRight size={14} />
) : ( )}
<IconFolder size={16} /> {isExpanded ? (
)} <IconFolderOpen size={16} />
{editingFolderId === folder.id ? ( ) : (
<TextInput <IconFolder size={16} />
size="xs" )}
value={editingName} {editingFolderId === folder.id ? (
onChange={(e) => setEditingName(e.target.value)} <TextInput
onBlur={saveEditFolder} size="xs"
onKeyDown={(e) => { value={editingName}
if (e.key === 'Enter') saveEditFolder(); onChange={(e) =>
}} setEditingName(
onClick={(e) => e.stopPropagation()} e.target.value,
autoFocus )
/> }
) : ( onBlur={saveEditFolder}
<Text onKeyDown={(e) => {
size="sm" if (e.key === 'Enter')
fw={500} saveEditFolder();
style={{ flex: 1 }} }}
onDoubleClick={(e) => { onClick={(e) =>
e.stopPropagation(); e.stopPropagation()
startEditFolder(folder); }
}} autoFocus
> />
{folder.name} ) : (
</Text> <Text
)} size="sm"
{editingFolderId !== folder.id && ( fw={500}
<ActionIcon style={{ flex: 1 }}
size="xs" onDoubleClick={(e) => {
variant="subtle" e.stopPropagation();
onClick={(e) => { startEditFolder(folder);
e.stopPropagation(); }}
handleDeleteFolder(folder, folderProjects.length); >
}} {folder.name}
aria-label="Delete folder" </Text>
> )}
<IconTrash size={12} /> {editingFolderId !== folder.id && (
</ActionIcon> <ActionIcon
)} size="xs"
</Group> variant="subtle"
<Collapse in={isExpanded}> onClick={(e) => {
<Stack gap={2} pl="md" mt={4}> e.stopPropagation();
{folderProjects.map((p) => renderProjectLink(p))} handleDeleteFolder(
</Stack> folder,
</Collapse> folderProjects.length,
</>, );
)} }}
</Box> aria-label="Delete folder"
); >
})} <IconTrash size={12} />
</ActionIcon>
)}
</Group>
<Collapse in={isExpanded}>
<Stack gap={2} pl="md" mt={4}>
{folderProjects.map((p) =>
renderProjectLink(p),
)}
</Stack>
</Collapse>
</>,
)}
</Box>
);
},
)}
</nav> </nav>
<Modal <Modal
opened={folderToDelete !== null} opened={folderToDelete !== null}
@@ -296,9 +331,11 @@ export function Sidebar() {
{folderToDelete && ( {folderToDelete && (
<> <>
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
This folder contains {folderToDelete.projectCount} project This folder contains {folderToDelete.projectCount}{' '}
{folderToDelete.projectCount === 1 ? '' : 's'}. They will be moved to project
Uncategorized. Delete folder &quot;{folderToDelete.folder.name}&quot;? {folderToDelete.projectCount === 1 ? '' : 's'}. They
will be moved to Uncategorized. Delete folder &quot;
{folderToDelete.folder.name}&quot;?
</Text> </Text>
<Group justify="flex-end" gap="xs"> <Group justify="flex-end" gap="xs">
<Button <Button

View File

@@ -11,7 +11,10 @@ interface ProjectsContextValue {
refetch: () => void; refetch: () => void;
updateProjectInList: (id: string, patch: Partial<ProjectItem>) => void; updateProjectInList: (id: string, patch: Partial<ProjectItem>) => void;
removeProjectFromList: (id: string) => void; removeProjectFromList: (id: string) => void;
moveProjectToFolder: (projectId: string, folderId: string | null) => Promise<void>; moveProjectToFolder: (
projectId: string,
folderId: string | null,
) => Promise<void>;
createFolder: (name?: string) => Promise<FolderItem | null>; createFolder: (name?: string) => Promise<FolderItem | null>;
updateFolder: (id: string, patch: Partial<FolderItem>) => Promise<void>; updateFolder: (id: string, patch: Partial<FolderItem>) => Promise<void>;
deleteFolder: (id: string) => Promise<void>; deleteFolder: (id: string) => Promise<void>;
@@ -27,11 +30,7 @@ export function useProjects(): ProjectsContextValue {
return ctx; return ctx;
} }
export function ProjectsProvider({ export function ProjectsProvider({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const [projects, setProjects] = useState<ProjectItem[]>([]); const [projects, setProjects] = useState<ProjectItem[]>([]);
const [folders, setFolders] = useState<FolderItem[]>([]); const [folders, setFolders] = useState<FolderItem[]>([]);
@@ -50,11 +49,14 @@ export function ProjectsProvider({
}); });
}, []); }, []);
const updateProjectInList = useCallback((id: string, patch: Partial<ProjectItem>) => { const updateProjectInList = useCallback(
setProjects((prev) => (id: string, patch: Partial<ProjectItem>) => {
prev.map((p) => (p.id === id ? { ...p, ...patch } : p)), setProjects((prev) =>
); prev.map((p) => (p.id === id ? { ...p, ...patch } : p)),
}, []); );
},
[],
);
const removeProjectFromList = useCallback((id: string) => { const removeProjectFromList = useCallback((id: string) => {
setProjects((prev) => prev.filter((p) => p.id !== id)); setProjects((prev) => prev.filter((p) => p.id !== id));
@@ -81,22 +83,29 @@ export function ProjectsProvider({
}); });
if (!res.ok) return null; if (!res.ok) return null;
const folder = await res.json(); const folder = await res.json();
setFolders((prev) => [...prev, folder].sort((a, b) => a.sortOrder - b.sortOrder)); setFolders((prev) =>
[...prev, folder].sort((a, b) => a.sortOrder - b.sortOrder),
);
return folder; return folder;
}, []); }, []);
const updateFolder = useCallback(async (id: string, patch: Partial<FolderItem>) => { const updateFolder = useCallback(
const res = await fetch(`/api/folders/${id}`, { async (id: string, patch: Partial<FolderItem>) => {
method: 'PUT', const res = await fetch(`/api/folders/${id}`, {
headers: { 'Content-Type': 'application/json' }, method: 'PUT',
body: JSON.stringify(patch), headers: { 'Content-Type': 'application/json' },
}); body: JSON.stringify(patch),
if (!res.ok) return; });
const folder = await res.json(); if (!res.ok) return;
setFolders((prev) => const folder = await res.json();
prev.map((f) => (f.id === id ? folder : f)).sort((a, b) => a.sortOrder - b.sortOrder), setFolders((prev) =>
); prev
}, []); .map((f) => (f.id === id ? folder : f))
.sort((a, b) => a.sortOrder - b.sortOrder),
);
},
[],
);
const deleteFolder = useCallback(async (id: string) => { const deleteFolder = useCallback(async (id: string) => {
const res = await fetch(`/api/folders/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/folders/${id}`, { method: 'DELETE' });

View File

@@ -0,0 +1,166 @@
import { describe, it, expect } from 'vitest';
import {
buildQrStylingOptions,
makeGradient,
type QrStylingOverrides,
} from './qrStylingOptions';
describe('buildQrStylingOptions', () => {
it('uses recipe defaults when minimal recipe', () => {
const opts = buildQrStylingOptions({});
expect(opts.width).toBe(256);
expect(opts.height).toBe(256);
expect(opts.data).toBe(' ');
expect(opts.shape).toBe('square');
expect(opts.margin).toBe(0);
expect(opts.qrOptions).toEqual({
type: 'canvas',
mode: 'Byte',
errorCorrectionLevel: 'M',
});
expect((opts.backgroundOptions as { color: string }).color).toBe(
'#ffffff',
);
expect((opts.dotsOptions as { type: string }).type).toBe('square');
expect((opts.cornersSquareOptions as { type: string }).type).toBe(
'square',
);
});
it('uses overrides for width, height, data', () => {
const overrides: QrStylingOverrides = {
width: 100,
height: 200,
data: 'https://x.com',
};
const opts = buildQrStylingOptions({}, overrides);
expect(opts.width).toBe(100);
expect(opts.height).toBe(200);
expect(opts.data).toBe('https://x.com');
});
it('includes gradient in backgroundOptions when set', () => {
const gradient = {
type: 'linear' as const,
rotation: 90,
colorStops: [
{ offset: 0, color: '#f00' },
{ offset: 1, color: '#00f' },
],
};
const opts = buildQrStylingOptions({
backgroundOptions: { color: '#fff', gradient },
});
expect(
(opts.backgroundOptions as { gradient: unknown }).gradient,
).toEqual(gradient);
});
it('includes gradient in dotsOptions and cornersSquareOptions when set', () => {
const gradient = {
type: 'radial' as const,
colorStops: [
{ offset: 0, color: '#000' },
{ offset: 1, color: '#fff' },
],
};
const opts = buildQrStylingOptions({
dotsOptions: { type: 'rounded', color: '#000', gradient },
cornersSquareOptions: { type: 'dot', color: '#000', gradient },
});
expect((opts.dotsOptions as { gradient: unknown }).gradient).toEqual(
gradient,
);
expect(
(opts.cornersSquareOptions as { gradient: unknown }).gradient,
).toEqual(gradient);
});
it('falls back cornersDotOptions to cornersSquareOptions', () => {
const opts = buildQrStylingOptions({
cornersSquareOptions: { type: 'extra-rounded', color: '#111' },
});
expect((opts.cornersDotOptions as { type: string }).type).toBe(
'extra-rounded',
);
expect((opts.cornersDotOptions as { color: string }).color).toBe(
'#111',
);
});
it('uses cornersDotOptions gradient when both set', () => {
const g1 = {
type: 'linear' as const,
colorStops: [
{ offset: 0, color: '#a' },
{ offset: 1, color: '#b' },
],
};
const g2 = {
type: 'radial' as const,
colorStops: [
{ offset: 0, color: '#c' },
{ offset: 1, color: '#d' },
],
};
const opts = buildQrStylingOptions({
cornersSquareOptions: { gradient: g1 },
cornersDotOptions: { gradient: g2 },
});
expect(
(opts.cornersDotOptions as { gradient: unknown }).gradient,
).toEqual(g2);
});
it('falls back cornersDotOptions gradient to cornersSquareOptions', () => {
const g = {
type: 'linear' as const,
colorStops: [
{ offset: 0, color: '#0' },
{ offset: 1, color: '#1' },
],
};
const opts = buildQrStylingOptions({
cornersSquareOptions: { type: 'dot', gradient: g },
});
expect(
(opts.cornersDotOptions as { gradient: unknown }).gradient,
).toEqual(g);
});
it('uses imageOptions and shape from recipe', () => {
const opts = buildQrStylingOptions({
imageOptions: {
hideBackgroundDots: false,
imageSize: 0.5,
margin: 5,
},
shape: 'circle',
});
expect(
(opts.imageOptions as { hideBackgroundDots: boolean })
.hideBackgroundDots,
).toBe(false);
expect((opts.imageOptions as { imageSize: number }).imageSize).toBe(
0.5,
);
expect((opts.imageOptions as { margin: number }).margin).toBe(5);
expect(opts.shape).toBe('circle');
});
});
describe('makeGradient', () => {
it('returns linear gradient with rotation', () => {
const g = makeGradient('linear', '#f00', '#0f0', 45);
expect(g.type).toBe('linear');
expect(g.rotation).toBe(45);
expect(g.colorStops).toHaveLength(2);
expect(g.colorStops[0]).toEqual({ offset: 0, color: '#f00' });
expect(g.colorStops[1]).toEqual({ offset: 1, color: '#0f0' });
});
it('defaults rotation to 0', () => {
const g = makeGradient('radial', '#000', '#fff');
expect(g.type).toBe('radial');
expect(g.rotation).toBe(0);
});
});

View File

@@ -67,7 +67,10 @@ export function buildQrStylingOptions(
const cornersDot = recipe.cornersDotOptions; const cornersDot = recipe.cornersDotOptions;
opts.cornersDotOptions = { opts.cornersDotOptions = {
type: (cornersDot?.type as CornerType) ?? (cornersSq?.type as CornerType) ?? 'square', type:
(cornersDot?.type as CornerType) ??
(cornersSq?.type as CornerType) ??
'square',
color: cornersDot?.color ?? cornersSq?.color ?? '#000000', color: cornersDot?.color ?? cornersSq?.color ?? '#000000',
...((cornersDot?.gradient ?? cornersSq?.gradient) && { ...((cornersDot?.gradient ?? cornersSq?.gradient) && {
gradient: cornersDot?.gradient ?? cornersSq?.gradient, gradient: cornersDot?.gradient ?? cornersSq?.gradient,

View File

@@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_RECIPE } from './project';
describe('project types', () => {
it('DEFAULT_RECIPE has expected shape', () => {
expect(DEFAULT_RECIPE.width).toBe(300);
expect(DEFAULT_RECIPE.height).toBe(300);
expect(DEFAULT_RECIPE.qrOptions?.errorCorrectionLevel).toBe('M');
expect(DEFAULT_RECIPE.backgroundOptions?.color).toBe('#ffffff');
expect(DEFAULT_RECIPE.dotsOptions?.color).toBe('#000000');
expect(DEFAULT_RECIPE.cornersSquareOptions?.type).toBe('square');
});
});

View File

@@ -27,7 +27,11 @@ export interface RecipeOptions {
contentType?: ContentType; contentType?: ContentType;
image?: string; image?: string;
qrOptions?: { type?: string; mode?: string; errorCorrectionLevel?: string }; qrOptions?: { type?: string; mode?: string; errorCorrectionLevel?: string };
imageOptions?: { hideBackgroundDots?: boolean; imageSize?: number; margin?: number }; imageOptions?: {
hideBackgroundDots?: boolean;
imageSize?: number;
margin?: number;
};
backgroundOptions?: { backgroundOptions?: {
color?: string; color?: string;
gradient?: QrGradient; gradient?: QrGradient;
@@ -39,8 +43,16 @@ export interface RecipeOptions {
gradient?: QrGradient; gradient?: QrGradient;
roundSize?: boolean; roundSize?: boolean;
}; };
cornersSquareOptions?: { color?: string; type?: string; gradient?: QrGradient }; cornersSquareOptions?: {
cornersDotOptions?: { color?: string; type?: string; gradient?: QrGradient }; color?: string;
type?: string;
gradient?: QrGradient;
};
cornersDotOptions?: {
color?: string;
type?: string;
gradient?: QrGradient;
};
shape?: 'square' | 'circle'; shape?: 'square' | 'circle';
margin?: number; margin?: number;
} }

View File

@@ -11,11 +11,23 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [{ "name": "next" }], "plugins": [
"paths": { "@/*": ["./src/*"] } {
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View File

@@ -8,6 +8,18 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
globals: false, globals: false,
include: ['src/**/*.test.{ts,tsx}'], include: ['src/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/lib/**/*.ts', 'src/types/**/*.ts'],
exclude: ['src/**/*.test.{ts,tsx}'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
}, },
resolve: { resolve: {
alias: { '@': path.resolve(__dirname, './src') }, alias: { '@': path.resolve(__dirname, './src') },