Resolve linter issues, add unit tests, adjust test coverage
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
18
AGENTS.md
18
AGENTS.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -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
17787
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
28
qr-api/eslint.config.cjs
Normal 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,
|
||||||
|
];
|
||||||
@@ -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
50
qr-api/src/app.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
37
qr-api/src/env.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
295
qr-api/src/routes.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
12
qr-api/src/upload.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
18
qr-web/eslint.config.cjs
Normal 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: '^_' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
2
qr-web/next-env.d.ts
vendored
2
qr-web/next-env.d.ts
vendored
@@ -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.
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 (7–15 digits)';
|
return digits.length >= 7 && digits.length <= 15
|
||||||
|
? null
|
||||||
|
: 'Enter a valid phone number (7–15 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 "{project.name || 'Untitled QR'}" will be
|
This cannot be undone. The project "
|
||||||
permanently deleted.
|
{project.name || 'Untitled QR'}" 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 "{folderToDelete.folder.name}"?
|
{folderToDelete.projectCount === 1 ? '' : 's'}. They
|
||||||
|
will be moved to Uncategorized. Delete folder "
|
||||||
|
{folderToDelete.folder.name}"?
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" gap="xs">
|
<Group justify="flex-end" gap="xs">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
166
qr-web/src/lib/qrStylingOptions.test.ts
Normal file
166
qr-web/src/lib/qrStylingOptions.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
13
qr-web/src/types/project.test.ts
Normal file
13
qr-web/src/types/project.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') },
|
||||||
|
|||||||
Reference in New Issue
Block a user