Resolve linter issues, add unit tests, adjust test coverage

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

View File

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

View File

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

View File

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

View File

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

View File

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

5671
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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