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)
|
||||
image: docker:latest
|
||||
environment:
|
||||
DOCKER_API_VERSION: "1.43"
|
||||
DOCKER_BUILDKIT: "1"
|
||||
BUILDKIT_PROGRESS: "plain"
|
||||
DOCKER_API_VERSION: '1.43'
|
||||
DOCKER_BUILDKIT: '1'
|
||||
BUILDKIT_PROGRESS: 'plain'
|
||||
REGISTRY_URL: git.mifi.dev
|
||||
REGISTRY_REPO_API: git.mifi.dev/mifi-holdings/shorty-qr-api
|
||||
REGISTRY_REPO_WEB: git.mifi.dev/mifi-holdings/shorty-qr-web
|
||||
@@ -52,6 +52,34 @@ steps:
|
||||
build_push ./qr-web $REGISTRY_REPO_WEB
|
||||
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
|
||||
image: curlimages/curl:latest
|
||||
environment:
|
||||
@@ -71,3 +99,31 @@ steps:
|
||||
echo "✓ Portainer redeploy triggered (HTTP $code)"
|
||||
depends_on:
|
||||
- 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]
|
||||
|
||||
@@ -38,7 +38,7 @@ shorty/
|
||||
## Key scripts (from repo root)
|
||||
|
||||
| Command | Effect |
|
||||
|-------------------|--------|
|
||||
| ----------------------- | ------------------------------------------ |
|
||||
| `pnpm install` | Install deps for all workspaces |
|
||||
| `pnpm run lint` | ESLint in qr-api and qr-web |
|
||||
| `pnpm run format:check` | Prettier check (no write) |
|
||||
@@ -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).
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ services:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME:-kutt}
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -43,29 +47,29 @@ services:
|
||||
environment:
|
||||
DB_CLIENT: pg
|
||||
DB_HOST: kutt_db
|
||||
DB_PORT: "5432"
|
||||
DB_PORT: '5432'
|
||||
DB_USER: ${DB_USER:-kutt}
|
||||
DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD}
|
||||
DB_NAME: ${DB_NAME:-kutt}
|
||||
REDIS_ENABLED: "true"
|
||||
REDIS_ENABLED: 'true'
|
||||
REDIS_HOST: kutt_redis
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_PORT: '6379'
|
||||
DEFAULT_DOMAIN: mifi.me
|
||||
NODE_ENV: production
|
||||
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "docker.network=marina-net"
|
||||
- "traefik.http.routers.kutt-mifi.rule=Host(`mifi.me`)"
|
||||
- "traefik.http.routers.kutt-mifi.entrypoints=websecure"
|
||||
- "traefik.http.routers.kutt-mifi.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.kutt-mifi.service=kutt-short"
|
||||
- "traefik.http.services.kutt-short.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.kutt-link.rule=Host(`link.mifi.me`)"
|
||||
- "traefik.http.routers.kutt-link.entrypoints=websecure"
|
||||
- "traefik.http.routers.kutt-link.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.kutt-link.service=kutt"
|
||||
- "traefik.http.services.kutt.loadbalancer.server.port=3000"
|
||||
- 'traefik.enable=true'
|
||||
- 'docker.network=marina-net'
|
||||
- 'traefik.http.routers.kutt-mifi.rule=Host(`mifi.me`)'
|
||||
- 'traefik.http.routers.kutt-mifi.entrypoints=websecure'
|
||||
- 'traefik.http.routers.kutt-mifi.tls.certresolver=letsencrypt'
|
||||
- 'traefik.http.routers.kutt-mifi.service=kutt-short'
|
||||
- 'traefik.http.services.kutt-short.loadbalancer.server.port=3000'
|
||||
- 'traefik.http.routers.kutt-link.rule=Host(`link.mifi.me`)'
|
||||
- 'traefik.http.routers.kutt-link.entrypoints=websecure'
|
||||
- 'traefik.http.routers.kutt-link.tls.certresolver=letsencrypt'
|
||||
- 'traefik.http.routers.kutt-link.service=kutt'
|
||||
- 'traefik.http.services.kutt.loadbalancer.server.port=3000'
|
||||
|
||||
qr_api:
|
||||
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/uploads:/uploads
|
||||
environment:
|
||||
PORT: "8080"
|
||||
PORT: '8080'
|
||||
DB_PATH: /data/db.sqlite
|
||||
UPLOADS_PATH: /uploads
|
||||
KUTT_API_KEY: ${KUTT_API_KEY:-}
|
||||
@@ -95,15 +99,15 @@ services:
|
||||
environment:
|
||||
QR_API_URL: http://qr_api:8080
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "docker.network=marina-net"
|
||||
- "traefik.http.routers.qr-web.rule=Host(`qr.mifi.dev`)"
|
||||
- "traefik.http.routers.qr-web.entrypoints=websecure"
|
||||
- "traefik.http.routers.qr-web.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.qr-web.service=qr-web"
|
||||
- "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.services.qr-web.loadbalancer.server.port=3000"
|
||||
- 'traefik.enable=true'
|
||||
- 'docker.network=marina-net'
|
||||
- 'traefik.http.routers.qr-web.rule=Host(`qr.mifi.dev`)'
|
||||
- 'traefik.http.routers.qr-web.entrypoints=websecure'
|
||||
- 'traefik.http.routers.qr-web.tls.certresolver=letsencrypt'
|
||||
- 'traefik.http.routers.qr-web.service=qr-web'
|
||||
- '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.services.qr-web.loadbalancer.server.port=3000'
|
||||
|
||||
networks:
|
||||
marina-net:
|
||||
|
||||
@@ -11,7 +11,11 @@ services:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME:-kutt}
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -39,29 +43,29 @@ services:
|
||||
environment:
|
||||
DB_CLIENT: pg
|
||||
DB_HOST: kutt_db
|
||||
DB_PORT: "5432"
|
||||
DB_PORT: '5432'
|
||||
DB_USER: ${DB_USER:-kutt}
|
||||
DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD}
|
||||
DB_NAME: ${DB_NAME:-kutt}
|
||||
REDIS_ENABLED: "true"
|
||||
REDIS_ENABLED: 'true'
|
||||
REDIS_HOST: kutt_redis
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_PORT: '6379'
|
||||
DEFAULT_DOMAIN: mifi.me
|
||||
NODE_ENV: production
|
||||
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "docker.network=marina-net"
|
||||
- "traefik.http.routers.kutt-mifi.rule=Host(`mifi.me`)"
|
||||
- "traefik.http.routers.kutt-mifi.entrypoints=websecure"
|
||||
- "traefik.http.routers.kutt-mifi.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.kutt-mifi.service=kutt-short"
|
||||
- "traefik.http.services.kutt-short.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.kutt-link.rule=Host(`link.mifi.me`)"
|
||||
- "traefik.http.routers.kutt-link.entrypoints=websecure"
|
||||
- "traefik.http.routers.kutt-link.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.kutt-link.service=kutt"
|
||||
- "traefik.http.services.kutt.loadbalancer.server.port=3000"
|
||||
- 'traefik.enable=true'
|
||||
- 'docker.network=marina-net'
|
||||
- 'traefik.http.routers.kutt-mifi.rule=Host(`mifi.me`)'
|
||||
- 'traefik.http.routers.kutt-mifi.entrypoints=websecure'
|
||||
- 'traefik.http.routers.kutt-mifi.tls.certresolver=letsencrypt'
|
||||
- 'traefik.http.routers.kutt-mifi.service=kutt-short'
|
||||
- 'traefik.http.services.kutt-short.loadbalancer.server.port=3000'
|
||||
- 'traefik.http.routers.kutt-link.rule=Host(`link.mifi.me`)'
|
||||
- 'traefik.http.routers.kutt-link.entrypoints=websecure'
|
||||
- 'traefik.http.routers.kutt-link.tls.certresolver=letsencrypt'
|
||||
- 'traefik.http.routers.kutt-link.service=kutt'
|
||||
- 'traefik.http.services.kutt.loadbalancer.server.port=3000'
|
||||
|
||||
qr_api:
|
||||
build:
|
||||
@@ -75,7 +79,7 @@ services:
|
||||
- /mnt/config/docker/qr/db:/data
|
||||
- /mnt/config/docker/qr/uploads:/uploads
|
||||
environment:
|
||||
PORT: "8080"
|
||||
PORT: '8080'
|
||||
DB_PATH: /data/db.sqlite
|
||||
UPLOADS_PATH: /uploads
|
||||
KUTT_API_KEY: ${KUTT_API_KEY:-}
|
||||
@@ -95,15 +99,15 @@ services:
|
||||
environment:
|
||||
QR_API_URL: http://qr_api:8080
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "docker.network=marina-net"
|
||||
- "traefik.http.routers.qr-web.rule=Host(`qr.mifi.dev`)"
|
||||
- "traefik.http.routers.qr-web.entrypoints=websecure"
|
||||
- "traefik.http.routers.qr-web.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.qr-web.service=qr-web"
|
||||
- "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.services.qr-web.loadbalancer.server.port=3000"
|
||||
- 'traefik.enable=true'
|
||||
- 'docker.network=marina-net'
|
||||
- 'traefik.http.routers.qr-web.rule=Host(`qr.mifi.dev`)'
|
||||
- 'traefik.http.routers.qr-web.entrypoints=websecure'
|
||||
- 'traefik.http.routers.qr-web.tls.certresolver=letsencrypt'
|
||||
- 'traefik.http.routers.qr-web.service=qr-web'
|
||||
- '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.services.qr-web.loadbalancer.server.port=3000'
|
||||
|
||||
networks:
|
||||
marina-net:
|
||||
|
||||
12
package.json
12
package.json
@@ -4,11 +4,14 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "pnpm -r run lint",
|
||||
"build": "pnpm -r run build",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "pnpm -r run lint",
|
||||
"lint:fix": "pnpm -r run lint:fix",
|
||||
"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": {
|
||||
"prettier": "^3.4.2"
|
||||
@@ -20,5 +23,10 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mifi.dev/mifi-holdings/shorty.git"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"glob": "^13.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5671
pnpm-lock.yaml
generated
5671
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,11 @@
|
||||
packages:
|
||||
- "qr-api"
|
||||
- "qr-web"
|
||||
- qr-api
|
||||
- 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",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"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:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.0.2",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^11.3.0",
|
||||
"zod": "^3.23.8"
|
||||
@@ -24,7 +28,7 @@
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
@@ -32,7 +36,9 @@
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.6"
|
||||
"vitest": "^2.1.6",
|
||||
"@vitest/coverage-v8": "^2.1.6",
|
||||
"supertest": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"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 fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import Database from 'better-sqlite3';
|
||||
import {
|
||||
initDb,
|
||||
@@ -7,6 +10,11 @@ import {
|
||||
getProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
listFolders,
|
||||
createFolder,
|
||||
getFolder,
|
||||
updateFolder,
|
||||
deleteFolder,
|
||||
} from './db.js';
|
||||
|
||||
const testEnv = {
|
||||
@@ -25,7 +33,10 @@ describe('db', () => {
|
||||
});
|
||||
|
||||
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.name).toBe('Test');
|
||||
expect(p.originalUrl).toBe('https://example.com');
|
||||
@@ -45,7 +56,10 @@ describe('db', () => {
|
||||
|
||||
it('updates a project', () => {
|
||||
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?.recipeJson).toBe('{"x":1}');
|
||||
});
|
||||
@@ -58,8 +72,110 @@ describe('db', () => {
|
||||
});
|
||||
|
||||
it('returns null for missing project', () => {
|
||||
expect(getProject(db, '00000000-0000-0000-0000-000000000000')).toBeNull();
|
||||
expect(updateProject(db, '00000000-0000-0000-0000-000000000000', { name: 'X' })).toBeNull();
|
||||
expect(deleteProject(db, '00000000-0000-0000-0000-000000000000')).toBe(false);
|
||||
expect(
|
||||
getProject(db, '00000000-0000-0000-0000-000000000000'),
|
||||
).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(
|
||||
`INSERT INTO projects (id, name, createdAt, updatedAt, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId)
|
||||
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)!;
|
||||
}
|
||||
|
||||
export function listProjects(db: Database.Database): Omit<Project, 'recipeJson' | 'originalUrl' | 'shortUrl' | 'shortenEnabled'>[] {
|
||||
const rows = db.prepare(
|
||||
export function listProjects(
|
||||
db: Database.Database,
|
||||
): Omit<
|
||||
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 }>;
|
||||
)
|
||||
.all() as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
logoFilename: string | null;
|
||||
folderId: string | null;
|
||||
}>;
|
||||
return rows;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -98,14 +125,29 @@ export function updateProject(
|
||||
const name = data.name ?? existing.name;
|
||||
const originalUrl = data.originalUrl ?? existing.originalUrl;
|
||||
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 logoFilename = data.logoFilename !== undefined ? data.logoFilename : existing.logoFilename;
|
||||
const folderId = data.folderId !== undefined ? data.folderId : existing.folderId;
|
||||
const logoFilename =
|
||||
data.logoFilename !== undefined
|
||||
? data.logoFilename
|
||||
: existing.logoFilename;
|
||||
const folderId =
|
||||
data.folderId !== undefined ? data.folderId : existing.folderId;
|
||||
|
||||
db.prepare(
|
||||
`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);
|
||||
}
|
||||
@@ -116,9 +158,11 @@ export function deleteProject(db: Database.Database, id: string): boolean {
|
||||
}
|
||||
|
||||
export function listFolders(db: Database.Database): Folder[] {
|
||||
const rows = db.prepare(
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT id, name, sortOrder FROM folders ORDER BY sortOrder ASC, name ASC',
|
||||
).all() as Folder[];
|
||||
)
|
||||
.all() as Folder[];
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -136,7 +180,9 @@ export function createFolder(
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -149,12 +195,18 @@ export function updateFolder(
|
||||
if (!existing) return null;
|
||||
const name = data.name ?? existing.name;
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
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 fs from 'fs';
|
||||
import pino from 'pino';
|
||||
import { loadEnv } from './env.js';
|
||||
import { initDb } from './db.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';
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const env = loadEnv();
|
||||
const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });
|
||||
@@ -24,30 +19,7 @@ for (const dir of [dataDir, uploadsDir]) {
|
||||
}
|
||||
|
||||
const db = initDb(env);
|
||||
|
||||
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 app = createApp(db, env, '', logger);
|
||||
|
||||
const port = env.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() });
|
||||
|
||||
export function projectsRouter(db: Database, baseUrl: string): ReturnType<typeof Router> {
|
||||
export function projectsRouter(
|
||||
db: Database,
|
||||
baseUrl: string,
|
||||
): ReturnType<typeof Router> {
|
||||
const router = Router();
|
||||
const toJson = (p: ReturnType<typeof getProject>) =>
|
||||
p
|
||||
? {
|
||||
...p,
|
||||
shortenEnabled: Boolean(p.shortenEnabled),
|
||||
logoUrl: p.logoFilename ? `${baseUrl}/uploads/${p.logoFilename}` : null,
|
||||
logoUrl: p.logoFilename
|
||||
? `${baseUrl}/uploads/${p.logoFilename}`
|
||||
: null,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -63,7 +68,9 @@ export function projectsRouter(db: Database, baseUrl: string): ReturnType<typeof
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
updatedAt: p.updatedAt,
|
||||
logoUrl: p.logoFilename ? `${baseUrl}/uploads/${p.logoFilename}` : null,
|
||||
logoUrl: p.logoFilename
|
||||
? `${baseUrl}/uploads/${p.logoFilename}`
|
||||
: null,
|
||||
folderId: p.folderId ?? null,
|
||||
}));
|
||||
return res.json(items);
|
||||
@@ -97,7 +104,12 @@ export function projectsRouter(db: Database, baseUrl: string): ReturnType<typeof
|
||||
const project = updateProject(db, paramParsed.data.id, {
|
||||
name: data.name,
|
||||
originalUrl: data.originalUrl,
|
||||
shortenEnabled: data.shortenEnabled !== undefined ? (data.shortenEnabled ? 1 : 0) : undefined,
|
||||
shortenEnabled:
|
||||
data.shortenEnabled !== undefined
|
||||
? data.shortenEnabled
|
||||
? 1
|
||||
: 0
|
||||
: undefined,
|
||||
shortUrl: data.shortUrl,
|
||||
recipeJson: data.recipeJson,
|
||||
logoFilename: data.logoFilename,
|
||||
|
||||
@@ -4,18 +4,25 @@ import fs from 'fs';
|
||||
import type { Env } from '../env.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 upload = createMulter(env);
|
||||
|
||||
router.post('/logo', upload.single('file'), (req: Request, res: Response) => {
|
||||
router.post(
|
||||
'/logo',
|
||||
upload.single('file'),
|
||||
(req: Request, res: Response) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
const filename = req.file.filename;
|
||||
const url = `${baseUrl}/uploads/${filename}`;
|
||||
return res.json({ filename, url });
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/:filename', (req: Request, res: Response) => {
|
||||
const raw = req.params.filename;
|
||||
|
||||
@@ -20,15 +20,21 @@ describe('shortenUrl', () => {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ link: 'https://mifi.me/abc' }),
|
||||
});
|
||||
const result = await shortenUrl(env as Parameters<typeof shortenUrl>[0], {
|
||||
const result = await shortenUrl(
|
||||
env as Parameters<typeof shortenUrl>[0],
|
||||
{
|
||||
targetUrl: 'https://example.com',
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(result.shortUrl).toBe('https://mifi.me/abc');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'http://kutt:3000/api/v2/links',
|
||||
expect.objectContaining({
|
||||
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' }),
|
||||
}),
|
||||
);
|
||||
@@ -46,16 +52,24 @@ describe('shortenUrl', () => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
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 () => {
|
||||
await expect(
|
||||
shortenUrl({ ...env, KUTT_API_KEY: undefined } as Parameters<typeof shortenUrl>[0], {
|
||||
shortenUrl(
|
||||
{ ...env, KUTT_API_KEY: undefined } as Parameters<
|
||||
typeof shortenUrl
|
||||
>[0],
|
||||
{
|
||||
targetUrl: 'https://example.com',
|
||||
}),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('KUTT_API_KEY');
|
||||
});
|
||||
|
||||
@@ -66,7 +80,45 @@ describe('shortenUrl', () => {
|
||||
text: () => Promise.resolve('Bad request'),
|
||||
});
|
||||
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/);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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 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) {
|
||||
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 };
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
export function createMulter(env: Env) {
|
||||
@@ -20,7 +26,11 @@ export function createMulter(env: Env) {
|
||||
if (IMAGE_MIME.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} 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: {
|
||||
globals: false,
|
||||
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/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -15,11 +19,11 @@
|
||||
"@tabler/icons-react": "^3.23.0",
|
||||
"@mantine/dropzone": "^7.14.0",
|
||||
"@mantine/hooks": "^7.14.0",
|
||||
"next": "^15.0.3",
|
||||
"next": "^16.1.6",
|
||||
"pdf-lib": "^1.4.2",
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.0.1",
|
||||
@@ -29,7 +33,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-next": "^15.0.3",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"postcss": "^8.4.49",
|
||||
@@ -37,6 +41,7 @@
|
||||
"postcss-preset-env": "^10.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.6",
|
||||
"@vitest/coverage-v8": "^2.1.6",
|
||||
"@vitejs/plugin-react": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -6,10 +6,15 @@ export async function GET(
|
||||
) {
|
||||
const { id } = await params;
|
||||
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();
|
||||
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);
|
||||
} catch (e) {
|
||||
@@ -31,7 +36,10 @@ export async function PUT(
|
||||
});
|
||||
const data = await res.json();
|
||||
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);
|
||||
} catch (e) {
|
||||
@@ -45,12 +53,17 @@ export async function DELETE(
|
||||
) {
|
||||
const { id } = await params;
|
||||
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) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
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) {
|
||||
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 data = await res.json();
|
||||
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);
|
||||
} catch (e) {
|
||||
@@ -23,7 +26,10 @@ export async function POST(request: Request) {
|
||||
});
|
||||
const data = await res.json();
|
||||
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);
|
||||
} catch (e) {
|
||||
|
||||
@@ -6,13 +6,21 @@ export async function GET(
|
||||
) {
|
||||
const { id } = await params;
|
||||
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();
|
||||
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) {
|
||||
data.logoUrl = data.logoUrl.replace(/^\/uploads\//, '/api/uploads/');
|
||||
data.logoUrl = data.logoUrl.replace(
|
||||
/^\/uploads\//,
|
||||
'/api/uploads/',
|
||||
);
|
||||
}
|
||||
return Response.json(data);
|
||||
} catch (e) {
|
||||
@@ -34,10 +42,16 @@ export async function PUT(
|
||||
});
|
||||
const data = await res.json();
|
||||
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) {
|
||||
data.logoUrl = data.logoUrl.replace(/^\/uploads\//, '/api/uploads/');
|
||||
data.logoUrl = data.logoUrl.replace(
|
||||
/^\/uploads\//,
|
||||
'/api/uploads/',
|
||||
);
|
||||
}
|
||||
return Response.json(data);
|
||||
} catch (e) {
|
||||
@@ -51,12 +65,17 @@ export async function DELETE(
|
||||
) {
|
||||
const { id } = await params;
|
||||
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) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
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) {
|
||||
return Response.json({ error: String(e) }, { status: 502 });
|
||||
}
|
||||
|
||||
@@ -9,10 +9,15 @@ function rewriteLogoUrl(items: Array<{ logoUrl?: string | null }>) {
|
||||
|
||||
export async function GET() {
|
||||
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();
|
||||
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);
|
||||
} catch (e) {
|
||||
@@ -30,7 +35,10 @@ export async function POST(request: Request) {
|
||||
});
|
||||
const data = await res.json();
|
||||
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);
|
||||
} catch (e) {
|
||||
|
||||
@@ -10,7 +10,10 @@ export async function POST(request: Request) {
|
||||
});
|
||||
const data = await res.json();
|
||||
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);
|
||||
} catch (e) {
|
||||
|
||||
@@ -8,5 +8,8 @@ body {
|
||||
margin: 0;
|
||||
background: var(--app-bg);
|
||||
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 classes from './layout.module.css';
|
||||
|
||||
function ProjectsLayoutInner({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
function ProjectsLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
const { refetch } = useProjects();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,14 +20,25 @@ import {
|
||||
Button,
|
||||
ActionIcon,
|
||||
} 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 { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
|
||||
import { useDebouncedCallback } from '@mantine/hooks';
|
||||
import { QrPreview } from './QrPreview';
|
||||
import { ExportPanel } from './ExportPanel';
|
||||
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 classes from './Editor.module.css';
|
||||
|
||||
@@ -49,7 +60,11 @@ const CONTENT_TYPES: Array<{
|
||||
placeholder: 'https://example.com',
|
||||
inputLabel: 'Website address',
|
||||
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',
|
||||
@@ -76,7 +91,9 @@ const CONTENT_TYPES: Array<{
|
||||
validate: (v) => {
|
||||
if (!v.trim()) return 'Enter an email address';
|
||||
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) => {
|
||||
if (!v.trim()) return 'Enter a phone number';
|
||||
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 {
|
||||
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();
|
||||
if (/^https?:\/\//i.test(t)) return 'url';
|
||||
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) return 'email';
|
||||
@@ -229,11 +249,16 @@ export function Editor({ id }: EditorProps) {
|
||||
shortenEnabled: true,
|
||||
recipeJson: (() => {
|
||||
try {
|
||||
const recipe = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
|
||||
const recipe = JSON.parse(
|
||||
project.recipeJson || '{}',
|
||||
) as RecipeOptions;
|
||||
recipe.data = data.shortUrl;
|
||||
return JSON.stringify(recipe);
|
||||
} 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;
|
||||
setContentTouched(false);
|
||||
try {
|
||||
const r = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
|
||||
const r = JSON.parse(
|
||||
project.recipeJson || '{}',
|
||||
) as RecipeOptions;
|
||||
r.contentType = type;
|
||||
const patch: Partial<Project> = { recipeJson: JSON.stringify(r) };
|
||||
if (type !== 'url' && (project.shortenEnabled || project.shortUrl)) {
|
||||
const patch: Partial<Project> = {
|
||||
recipeJson: JSON.stringify(r),
|
||||
};
|
||||
if (
|
||||
type !== 'url' &&
|
||||
(project.shortenEnabled || project.shortUrl)
|
||||
) {
|
||||
patch.shortenEnabled = false;
|
||||
patch.shortUrl = null;
|
||||
r.data = (project.originalUrl ?? '') || undefined;
|
||||
@@ -293,15 +325,23 @@ export function Editor({ id }: EditorProps) {
|
||||
const content = project.originalUrl ?? '';
|
||||
let recipe: RecipeOptions = {};
|
||||
try {
|
||||
recipe = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
|
||||
recipe = JSON.parse(
|
||||
project.recipeJson || '{}',
|
||||
) as RecipeOptions;
|
||||
} catch {
|
||||
recipe = {};
|
||||
}
|
||||
const contentType = inferContentType(content, recipe.contentType);
|
||||
try {
|
||||
const r = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
|
||||
const r = JSON.parse(
|
||||
project.recipeJson || '{}',
|
||||
) as RecipeOptions;
|
||||
r.contentType = contentType;
|
||||
if (contentType === 'url' && project.shortenEnabled && project.shortUrl) {
|
||||
if (
|
||||
contentType === 'url' &&
|
||||
project.shortenEnabled &&
|
||||
project.shortUrl
|
||||
) {
|
||||
r.data = project.shortUrl;
|
||||
} else {
|
||||
r.data = value || undefined;
|
||||
@@ -310,7 +350,10 @@ export function Editor({ id }: EditorProps) {
|
||||
originalUrl: value,
|
||||
recipeJson: JSON.stringify(r),
|
||||
};
|
||||
if (contentType !== 'url' && (project.shortenEnabled || project.shortUrl)) {
|
||||
if (
|
||||
contentType !== 'url' &&
|
||||
(project.shortenEnabled || project.shortUrl)
|
||||
) {
|
||||
patch.shortenEnabled = false;
|
||||
patch.shortUrl = null;
|
||||
r.data = value || undefined;
|
||||
@@ -352,7 +395,8 @@ export function Editor({ id }: EditorProps) {
|
||||
}
|
||||
const content = project.originalUrl ?? '';
|
||||
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 isUrl = contentType === 'url';
|
||||
const qrData =
|
||||
@@ -366,7 +410,11 @@ export function Editor({ id }: EditorProps) {
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{saving ? 'Saving…' : lastSaved ? `Saved ${lastSaved.toLocaleTimeString()}` : ''}
|
||||
{saving
|
||||
? 'Saving…'
|
||||
: lastSaved
|
||||
? `Saved ${lastSaved.toLocaleTimeString()}`
|
||||
: ''}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
@@ -382,7 +430,9 @@ export function Editor({ id }: EditorProps) {
|
||||
label="Project name"
|
||||
placeholder="Untitled QR"
|
||||
value={project.name}
|
||||
onChange={(e) => updateProject({ name: e.target.value })}
|
||||
onChange={(e) =>
|
||||
updateProject({ name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Text size="sm" fw={500}>
|
||||
Content type
|
||||
@@ -390,7 +440,10 @@ export function Editor({ id }: EditorProps) {
|
||||
<SegmentedControl
|
||||
value={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
|
||||
/>
|
||||
<TextInput
|
||||
@@ -418,8 +471,11 @@ export function Editor({ id }: EditorProps) {
|
||||
checked={project.shortenEnabled}
|
||||
onChange={(e) => {
|
||||
const checked = e.currentTarget.checked;
|
||||
updateProject({ shortenEnabled: checked });
|
||||
if (checked && project.originalUrl) handleShorten();
|
||||
updateProject({
|
||||
shortenEnabled: checked,
|
||||
});
|
||||
if (checked && project.originalUrl)
|
||||
handleShorten();
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
@@ -451,24 +507,33 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.imageOptions?.imageSize ?? 0.4}
|
||||
onChange={(n) => {
|
||||
const r = { ...recipe };
|
||||
const v = typeof n === 'string' ? parseFloat(n) : n;
|
||||
const v =
|
||||
typeof n === 'string' ? parseFloat(n) : n;
|
||||
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
|
||||
label="Hide dots behind logo"
|
||||
checked={recipe.imageOptions?.hideBackgroundDots ?? true}
|
||||
checked={
|
||||
recipe.imageOptions?.hideBackgroundDots ?? true
|
||||
}
|
||||
onChange={(e) => {
|
||||
const r = { ...recipe };
|
||||
r.imageOptions = {
|
||||
...r.imageOptions,
|
||||
hideBackgroundDots: e.currentTarget.checked,
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
@@ -477,7 +542,9 @@ export function Editor({ id }: EditorProps) {
|
||||
Foreground
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={recipe.dotsOptions?.gradient ? 'gradient' : 'solid'}
|
||||
value={
|
||||
recipe.dotsOptions?.gradient ? 'gradient' : 'solid'
|
||||
}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
if (v === 'gradient') {
|
||||
@@ -487,13 +554,42 @@ export function Editor({ id }: EditorProps) {
|
||||
'#444444',
|
||||
0,
|
||||
);
|
||||
r.dotsOptions = { ...r.dotsOptions, gradient: g, color: undefined };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g, color: undefined };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g, color: undefined };
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
gradient: g,
|
||||
color: undefined,
|
||||
};
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
gradient: g,
|
||||
color: undefined,
|
||||
};
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
gradient: g,
|
||||
color: undefined,
|
||||
};
|
||||
} else {
|
||||
r.dotsOptions = { ...r.dotsOptions, 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' };
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
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) });
|
||||
}}
|
||||
@@ -517,61 +613,147 @@ export function Editor({ id }: EditorProps) {
|
||||
const r = { ...recipe };
|
||||
const g: QrGradient = {
|
||||
...recipe.dotsOptions!.gradient!,
|
||||
type: (v as 'linear' | 'radial') ?? 'linear',
|
||||
type:
|
||||
(v as 'linear' | 'radial') ??
|
||||
'linear',
|
||||
};
|
||||
r.dotsOptions = { ...r.dotsOptions, gradient: g };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g };
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
gradient: g,
|
||||
};
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
gradient: g,
|
||||
};
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
gradient: g,
|
||||
};
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Rotation (°)"
|
||||
min={0}
|
||||
max={360}
|
||||
value={recipe.dotsOptions.gradient.rotation ?? 0}
|
||||
value={
|
||||
recipe.dotsOptions.gradient.rotation ??
|
||||
0
|
||||
}
|
||||
onChange={(n) => {
|
||||
const r = { ...recipe };
|
||||
const g: QrGradient = {
|
||||
...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.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g };
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
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 grow>
|
||||
<ColorInput
|
||||
label="Start color"
|
||||
value={recipe.dotsOptions.gradient.colorStops[0]?.color ?? '#000000'}
|
||||
value={
|
||||
recipe.dotsOptions.gradient
|
||||
.colorStops[0]?.color ?? '#000000'
|
||||
}
|
||||
onChange={(c) => {
|
||||
const r = { ...recipe };
|
||||
const stops = [...(recipe.dotsOptions!.gradient!.colorStops || [])];
|
||||
if (stops[0]) stops[0] = { ...stops[0], 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) });
|
||||
const stops = [
|
||||
...(recipe.dotsOptions!.gradient!
|
||||
.colorStops || []),
|
||||
];
|
||||
if (stops[0])
|
||||
stops[0] = {
|
||||
...stops[0],
|
||||
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
|
||||
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) => {
|
||||
const r = { ...recipe };
|
||||
const stops = [...(recipe.dotsOptions!.gradient!.colorStops || [])];
|
||||
if (stops[1]) stops[1] = { ...stops[1], 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) });
|
||||
const stops = [
|
||||
...(recipe.dotsOptions!.gradient!
|
||||
.colorStops || []),
|
||||
];
|
||||
if (stops[1])
|
||||
stops[1] = {
|
||||
...stops[1],
|
||||
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>
|
||||
@@ -583,9 +765,17 @@ export function Editor({ id }: EditorProps) {
|
||||
onChange={(c) => {
|
||||
const r = { ...recipe };
|
||||
r.dotsOptions = { ...r.dotsOptions, color: c };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, color: c };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, color: c };
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
color: c,
|
||||
};
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
color: c,
|
||||
};
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -593,7 +783,11 @@ export function Editor({ id }: EditorProps) {
|
||||
Background
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={recipe.backgroundOptions?.gradient ? 'gradient' : 'solid'}
|
||||
value={
|
||||
recipe.backgroundOptions?.gradient
|
||||
? 'gradient'
|
||||
: 'solid'
|
||||
}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
if (v === 'gradient') {
|
||||
@@ -601,7 +795,8 @@ export function Editor({ id }: EditorProps) {
|
||||
...r.backgroundOptions,
|
||||
gradient: makeGradient(
|
||||
'linear',
|
||||
recipe.backgroundOptions?.color ?? '#ffffff',
|
||||
recipe.backgroundOptions?.color ??
|
||||
'#ffffff',
|
||||
'#e0e0e0',
|
||||
0,
|
||||
),
|
||||
@@ -611,7 +806,9 @@ export function Editor({ id }: EditorProps) {
|
||||
r.backgroundOptions = {
|
||||
...r.backgroundOptions,
|
||||
gradient: undefined,
|
||||
color: recipe.backgroundOptions?.color ?? '#ffffff',
|
||||
color:
|
||||
recipe.backgroundOptions?.color ??
|
||||
'#ffffff',
|
||||
};
|
||||
}
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
@@ -631,66 +828,123 @@ export function Editor({ id }: EditorProps) {
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
{ value: 'radial', label: 'Radial' },
|
||||
]}
|
||||
value={recipe.backgroundOptions.gradient.type}
|
||||
value={
|
||||
recipe.backgroundOptions.gradient.type
|
||||
}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
r.backgroundOptions = {
|
||||
...r.backgroundOptions,
|
||||
gradient: {
|
||||
...recipe.backgroundOptions!.gradient!,
|
||||
type: (v as 'linear' | 'radial') ?? 'linear',
|
||||
...recipe.backgroundOptions!
|
||||
.gradient!,
|
||||
type:
|
||||
(v as
|
||||
| 'linear'
|
||||
| 'radial') ?? 'linear',
|
||||
},
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Rotation (°)"
|
||||
min={0}
|
||||
max={360}
|
||||
value={recipe.backgroundOptions.gradient.rotation ?? 0}
|
||||
value={
|
||||
recipe.backgroundOptions.gradient
|
||||
.rotation ?? 0
|
||||
}
|
||||
onChange={(n) => {
|
||||
const r = { ...recipe };
|
||||
r.backgroundOptions = {
|
||||
...r.backgroundOptions,
|
||||
gradient: {
|
||||
...recipe.backgroundOptions!.gradient!,
|
||||
rotation: typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0,
|
||||
...recipe.backgroundOptions!
|
||||
.gradient!,
|
||||
rotation:
|
||||
typeof n === 'string'
|
||||
? parseInt(n, 10) || 0
|
||||
: (n ?? 0),
|
||||
},
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<ColorInput
|
||||
label="Start color"
|
||||
value={recipe.backgroundOptions.gradient.colorStops[0]?.color ?? '#ffffff'}
|
||||
value={
|
||||
recipe.backgroundOptions.gradient
|
||||
.colorStops[0]?.color ?? '#ffffff'
|
||||
}
|
||||
onChange={(c) => {
|
||||
const r = { ...recipe };
|
||||
const stops = [...(recipe.backgroundOptions!.gradient!.colorStops || [])];
|
||||
if (stops[0]) stops[0] = { ...stops[0], color: c };
|
||||
else stops.unshift({ offset: 0, color: c });
|
||||
const stops = [
|
||||
...(recipe.backgroundOptions!
|
||||
.gradient!.colorStops || []),
|
||||
];
|
||||
if (stops[0])
|
||||
stops[0] = {
|
||||
...stops[0],
|
||||
color: c,
|
||||
};
|
||||
else
|
||||
stops.unshift({
|
||||
offset: 0,
|
||||
color: c,
|
||||
});
|
||||
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
|
||||
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) => {
|
||||
const r = { ...recipe };
|
||||
const stops = [...(recipe.backgroundOptions!.gradient!.colorStops || [])];
|
||||
if (stops[1]) stops[1] = { ...stops[1], color: c };
|
||||
else stops.push({ offset: 1, color: c });
|
||||
const stops = [
|
||||
...(recipe.backgroundOptions!
|
||||
.gradient!.colorStops || []),
|
||||
];
|
||||
if (stops[1])
|
||||
stops[1] = {
|
||||
...stops[1],
|
||||
color: c,
|
||||
};
|
||||
else
|
||||
stops.push({ offset: 1, color: c });
|
||||
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>
|
||||
@@ -701,8 +955,13 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.backgroundOptions?.color ?? '#ffffff'}
|
||||
onChange={(c) => {
|
||||
const r = { ...recipe };
|
||||
r.backgroundOptions = { ...r.backgroundOptions, color: c };
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
r.backgroundOptions = {
|
||||
...r.backgroundOptions,
|
||||
color: c,
|
||||
};
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -712,7 +971,10 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.dotsOptions?.type ?? 'square'}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
r.dotsOptions = { ...r.dotsOptions, type: v ?? 'square' };
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
type: v ?? 'square',
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
}}
|
||||
/>
|
||||
@@ -721,7 +983,10 @@ export function Editor({ id }: EditorProps) {
|
||||
checked={recipe.dotsOptions?.roundSize ?? false}
|
||||
onChange={(e) => {
|
||||
const r = { ...recipe };
|
||||
r.dotsOptions = { ...r.dotsOptions, roundSize: e.currentTarget.checked };
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
roundSize: e.currentTarget.checked,
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
}}
|
||||
/>
|
||||
@@ -731,17 +996,27 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.cornersSquareOptions?.type ?? 'square'}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, type: v ?? 'square' };
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
type: v ?? 'square',
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Corner dot style"
|
||||
data={CORNER_TYPES}
|
||||
value={recipe.cornersDotOptions?.type ?? recipe.cornersSquareOptions?.type ?? 'square'}
|
||||
value={
|
||||
recipe.cornersDotOptions?.type ??
|
||||
recipe.cornersSquareOptions?.type ??
|
||||
'square'
|
||||
}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, type: v ?? 'square' };
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
type: v ?? 'square',
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
}}
|
||||
/>
|
||||
@@ -766,8 +1041,13 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.margin ?? 0}
|
||||
onChange={(n) => {
|
||||
const r = { ...recipe };
|
||||
r.margin = typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0;
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
r.margin =
|
||||
typeof n === 'string'
|
||||
? parseInt(n, 10) || 0
|
||||
: (n ?? 0);
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
@@ -779,9 +1059,14 @@ export function Editor({ id }: EditorProps) {
|
||||
const r = { ...recipe };
|
||||
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>
|
||||
@@ -791,7 +1076,10 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.qrOptions?.errorCorrectionLevel ?? 'M'}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
r.qrOptions = { ...r.qrOptions, errorCorrectionLevel: v ?? 'M' };
|
||||
r.qrOptions = {
|
||||
...r.qrOptions,
|
||||
errorCorrectionLevel: v ?? 'M',
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
}}
|
||||
/>
|
||||
@@ -822,11 +1110,15 @@ export function Editor({ id }: EditorProps) {
|
||||
centered
|
||||
>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
This cannot be undone. The project "{project.name || 'Untitled QR'}" will be
|
||||
permanently deleted.
|
||||
This cannot be undone. The project "
|
||||
{project.name || 'Untitled QR'}" will be permanently
|
||||
deleted.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="subtle" onClick={() => setDeleteConfirmOpen(false)}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setDeleteConfirmOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDeleteProject}>
|
||||
@@ -837,4 +1129,3 @@ export function Editor({ id }: EditorProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,12 @@ interface ExportPanelProps {
|
||||
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 getQrInstance = useCallback(() => {
|
||||
@@ -27,10 +32,14 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
|
||||
image: logoUrl || undefined,
|
||||
});
|
||||
if (qrRef.current) {
|
||||
qrRef.current.update(opts as Parameters<QRCodeStyling['update']>[0]);
|
||||
qrRef.current.update(
|
||||
opts as Parameters<QRCodeStyling['update']>[0],
|
||||
);
|
||||
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;
|
||||
return qr;
|
||||
}, [data, recipe, logoUrl]);
|
||||
@@ -48,7 +57,10 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
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();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [getQrInstance, projectName]);
|
||||
@@ -61,7 +73,10 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
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();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [getQrInstance, projectName]);
|
||||
@@ -92,10 +107,15 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
|
||||
page.drawText(urlText, { x: 50, y: 60, size: 10 });
|
||||
}
|
||||
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');
|
||||
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();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [getQrInstance, data, projectName]);
|
||||
@@ -106,13 +126,28 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
|
||||
Export
|
||||
</Text>
|
||||
<Group>
|
||||
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handleSvg}>
|
||||
<Button
|
||||
leftSection={<IconDownload size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={handleSvg}
|
||||
>
|
||||
SVG
|
||||
</Button>
|
||||
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handlePng}>
|
||||
<Button
|
||||
leftSection={<IconDownload size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={handlePng}
|
||||
>
|
||||
PNG
|
||||
</Button>
|
||||
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handlePdf}>
|
||||
<Button
|
||||
leftSection={<IconDownload size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={handlePdf}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -12,7 +12,12 @@ interface QrPreviewProps {
|
||||
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 qrRef = useRef<QRCodeStyling | null>(null);
|
||||
|
||||
|
||||
@@ -56,7 +56,9 @@ export function Sidebar() {
|
||||
deleteFolder,
|
||||
} = 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 [editingFolderId, setEditingFolderId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
@@ -74,10 +76,13 @@ export function Sidebar() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, projectId: string) => {
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, projectId: string) => {
|
||||
e.dataTransfer.setData('application/x-project-id', projectId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, zoneId: string) => {
|
||||
e.preventDefault();
|
||||
@@ -93,15 +98,20 @@ export function Sidebar() {
|
||||
(e: React.DragEvent, targetFolderId: string | null) => {
|
||||
e.preventDefault();
|
||||
setDragOverId(null);
|
||||
const projectId = e.dataTransfer.getData('application/x-project-id');
|
||||
const projectId = e.dataTransfer.getData(
|
||||
'application/x-project-id',
|
||||
);
|
||||
if (!projectId) return;
|
||||
const fid = targetFolderId === UNCATEGORIZED_ID ? null : targetFolderId;
|
||||
const fid =
|
||||
targetFolderId === UNCATEGORIZED_ID ? null : targetFolderId;
|
||||
moveProjectToFolder(projectId, fid);
|
||||
},
|
||||
[moveProjectToFolder],
|
||||
);
|
||||
|
||||
const uncategorized = projects.filter((p) => !p.folderId || p.folderId === '');
|
||||
const uncategorized = projects.filter(
|
||||
(p) => !p.folderId || p.folderId === '',
|
||||
);
|
||||
const projectsByFolder = folders.map((f) => ({
|
||||
folder: f,
|
||||
projects: projects.filter((p) => p.folderId === f.id),
|
||||
@@ -191,7 +201,10 @@ export function Sidebar() {
|
||||
leftSection={<IconFolderPlus size={16} />}
|
||||
onClick={() => {
|
||||
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',
|
||||
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
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
@@ -213,7 +232,8 @@ export function Sidebar() {
|
||||
</Stack>
|
||||
</>,
|
||||
)}
|
||||
{projectsByFolder.map(({ folder, projects: folderProjects }) => {
|
||||
{projectsByFolder.map(
|
||||
({ folder, projects: folderProjects }) => {
|
||||
const isExpanded = expandedIds.has(folder.id);
|
||||
return (
|
||||
<Box key={folder.id}>
|
||||
@@ -225,7 +245,9 @@ export function Sidebar() {
|
||||
<Group
|
||||
gap={4}
|
||||
className={classes.folderHeader}
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
onClick={() =>
|
||||
toggleFolder(folder.id)
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<IconChevronDown size={14} />
|
||||
@@ -241,12 +263,19 @@ export function Sidebar() {
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setEditingName(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
onBlur={saveEditFolder}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveEditFolder();
|
||||
if (e.key === 'Enter')
|
||||
saveEditFolder();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
@@ -268,7 +297,10 @@ export function Sidebar() {
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteFolder(folder, folderProjects.length);
|
||||
handleDeleteFolder(
|
||||
folder,
|
||||
folderProjects.length,
|
||||
);
|
||||
}}
|
||||
aria-label="Delete folder"
|
||||
>
|
||||
@@ -278,14 +310,17 @@ export function Sidebar() {
|
||||
</Group>
|
||||
<Collapse in={isExpanded}>
|
||||
<Stack gap={2} pl="md" mt={4}>
|
||||
{folderProjects.map((p) => renderProjectLink(p))}
|
||||
{folderProjects.map((p) =>
|
||||
renderProjectLink(p),
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</>,
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
},
|
||||
)}
|
||||
</nav>
|
||||
<Modal
|
||||
opened={folderToDelete !== null}
|
||||
@@ -296,9 +331,11 @@ export function Sidebar() {
|
||||
{folderToDelete && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
This folder contains {folderToDelete.projectCount} project
|
||||
{folderToDelete.projectCount === 1 ? '' : 's'}. They will be moved to
|
||||
Uncategorized. Delete folder "{folderToDelete.folder.name}"?
|
||||
This folder contains {folderToDelete.projectCount}{' '}
|
||||
project
|
||||
{folderToDelete.projectCount === 1 ? '' : 's'}. They
|
||||
will be moved to Uncategorized. Delete folder "
|
||||
{folderToDelete.folder.name}"?
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button
|
||||
|
||||
@@ -11,7 +11,10 @@ interface ProjectsContextValue {
|
||||
refetch: () => void;
|
||||
updateProjectInList: (id: string, patch: Partial<ProjectItem>) => 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>;
|
||||
updateFolder: (id: string, patch: Partial<FolderItem>) => Promise<void>;
|
||||
deleteFolder: (id: string) => Promise<void>;
|
||||
@@ -27,11 +30,7 @@ export function useProjects(): ProjectsContextValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ProjectsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function ProjectsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [projects, setProjects] = useState<ProjectItem[]>([]);
|
||||
const [folders, setFolders] = useState<FolderItem[]>([]);
|
||||
|
||||
@@ -50,11 +49,14 @@ export function ProjectsProvider({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateProjectInList = useCallback((id: string, patch: Partial<ProjectItem>) => {
|
||||
const updateProjectInList = useCallback(
|
||||
(id: string, patch: Partial<ProjectItem>) => {
|
||||
setProjects((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, ...patch } : p)),
|
||||
);
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeProjectFromList = useCallback((id: string) => {
|
||||
setProjects((prev) => prev.filter((p) => p.id !== id));
|
||||
@@ -81,11 +83,14 @@ export function ProjectsProvider({
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
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;
|
||||
}, []);
|
||||
|
||||
const updateFolder = useCallback(async (id: string, patch: Partial<FolderItem>) => {
|
||||
const updateFolder = useCallback(
|
||||
async (id: string, patch: Partial<FolderItem>) => {
|
||||
const res = await fetch(`/api/folders/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -94,9 +99,13 @@ export function ProjectsProvider({
|
||||
if (!res.ok) return;
|
||||
const folder = await res.json();
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => (f.id === id ? folder : f)).sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
prev
|
||||
.map((f) => (f.id === id ? folder : f))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const deleteFolder = useCallback(async (id: string) => {
|
||||
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;
|
||||
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',
|
||||
...((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;
|
||||
image?: string;
|
||||
qrOptions?: { type?: string; mode?: string; errorCorrectionLevel?: string };
|
||||
imageOptions?: { hideBackgroundDots?: boolean; imageSize?: number; margin?: number };
|
||||
imageOptions?: {
|
||||
hideBackgroundDots?: boolean;
|
||||
imageSize?: number;
|
||||
margin?: number;
|
||||
};
|
||||
backgroundOptions?: {
|
||||
color?: string;
|
||||
gradient?: QrGradient;
|
||||
@@ -39,8 +43,16 @@ export interface RecipeOptions {
|
||||
gradient?: QrGradient;
|
||||
roundSize?: boolean;
|
||||
};
|
||||
cornersSquareOptions?: { color?: string; type?: string; gradient?: QrGradient };
|
||||
cornersDotOptions?: { color?: string; type?: string; gradient?: QrGradient };
|
||||
cornersSquareOptions?: {
|
||||
color?: string;
|
||||
type?: string;
|
||||
gradient?: QrGradient;
|
||||
};
|
||||
cornersDotOptions?: {
|
||||
color?: string;
|
||||
type?: string;
|
||||
gradient?: QrGradient;
|
||||
};
|
||||
shape?: 'square' | 'circle';
|
||||
margin?: number;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,23 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
"plugins": [
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,18 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
globals: false,
|
||||
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: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
|
||||
Reference in New Issue
Block a user