Initial commit

This commit is contained in:
2026-02-07 11:03:53 -03:00
commit 84168f6f3c
64 changed files with 11402 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
{
"name": "Shorty (QR + Kutt)",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20",
"nodeGypDependencies": true,
"installYarnUsingApt": false,
"installPnpmUsingApt": true
}
},
"postCreateCommand": "pnpm install",
"forwardPorts": [3000, 8080],
"portsAttributes": {
"3000": { "label": "qr-web" },
"8080": { "label": "qr-api" }
},
"containerEnv": {
"DB_PATH": "${containerWorkspaceFolder}/.data/db.sqlite",
"UPLOADS_PATH": "${containerWorkspaceFolder}/.data/uploads",
"KUTT_API_KEY": "",
"QR_API_URL": "http://localhost:8080"
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-vscode.vscode-typescript-next"
]
}
}
}

21
.env.example Normal file
View File

@@ -0,0 +1,21 @@
# Docker Compose / production (Kutt + qr-api + qr-web)
# Copy to .env and set values before: docker compose up
# Required for Kutt (Postgres)
DB_USER=kutt
DB_PASSWORD=set-a-secure-password
DB_NAME=kutt
# Required for Kutt (JWT)
JWT_SECRET=set-a-long-random-string
# Optional: Kutt API key for qr-api shorten feature (create in Kutt UI first)
KUTT_API_KEY=
# ---
# Local dev (qr-api only, when running pnpm --filter qr-api dev)
# Set in .env.local or in devcontainer.json containerEnv; qr-api uses:
# DB_PATH default /data/db.sqlite (use ./.data/db.sqlite for dev)
# UPLOADS_PATH default /uploads (use ./.data/uploads for dev)
# KUTT_API_KEY optional, for shorten
# QR_API_URL is set in devcontainer for qr-web (http://localhost:8080)

36
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,36 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
env: { node: true, es2022: true },
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
ignorePatterns: [
'node_modules',
'.pnpm-store',
'dist',
'.next',
'build',
'coverage',
'*.tsbuildinfo',
'.data',
],
overrides: [
{
files: ['**/*.ts', '**/*.tsx'],
parser: '@typescript-eslint/parser',
parserOptions: { project: null },
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
],
};

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Dependencies and lockfile (keep pnpm-lock.yaml committed for CI)
node_modules
.pnpm-store
# Build and runtime
.next
dist
build
coverage
*.tsbuildinfo
tsconfig.tsbuildinfo
# Data and secrets
*.sqlite
.env
.env.local
.env.*.local
uploads
.data
# OS and misc
.DS_Store
*.log
Thumbs.db

12
.prettierignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.pnpm-store
.next
dist
build
coverage
*.sqlite
*.tsbuildinfo
uploads
.data
# Generated / vendored
qr-web/next-env.d.ts

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"tabWidth": 4,
"useTabs": false,
"singleQuote": true,
"trailingComma": "all",
"semi": true
}

68
.woodpecker/ci.yml Normal file
View File

@@ -0,0 +1,68 @@
# CI: runs on every push. Install, lint, check, test, build (dev), e2e.
when:
- event: pull_request
- event: push
branch: main
- event: tag
- event: manual
steps:
install:
image: node:22-bookworm-slim
commands:
- corepack enable
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
prettier:
image: node:22-bookworm-slim
commands:
- corepack enable && corepack prepare pnpm@latest --activate
- pnpm run format:check
depends_on:
- install
lint:
image: node:22-bookworm-slim
commands:
- corepack enable && corepack prepare pnpm@latest --activate
- pnpm run lint
depends_on:
- prettier
test:
image: node:22-bookworm-slim
commands:
- corepack enable && corepack prepare pnpm@latest --activate
- pnpm run test
depends_on:
- lint
# build:
# image: node:22-bookworm-slim
# commands:
# - corepack enable && corepack prepare pnpm@latest --activate
# - pnpm run build
# depends_on:
# - test
# build-full:
# image: node:22-bookworm-slim
# commands:
# - apt-get update
# - apt-get install -y --no-install-recommends ca-certificates libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdrm2 libgbm1 libgtk-3-0 libnss3 libxcomposite1 libxdamage1 libxfixes3 libxkbcommon0 libxrandr2
# - rm -rf /var/lib/apt/lists/*
# - corepack enable && corepack prepare pnpm@latest --activate
# - pnpm run critical-css:install
# - pnpm run build:full
# depends_on:
# - build
# e2e:
# image: node:22-bookworm-slim
# commands:
# - corepack enable && corepack prepare pnpm@latest --activate
# - pnpm exec playwright install chromium --with-deps
# - pnpm run test:e2e
# depends_on:
# - build

93
.woodpecker/deploy.yml Normal file
View File

@@ -0,0 +1,93 @@
# Deploy: build image, push to registry, trigger Portainer stack redeploy.
# Runs on push/tag/manual to main only, after ci workflow succeeds.
when:
- branch: main
event: [push, tag, manual]
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "production"'
depends_on:
- ci
steps:
- name: Docker image build
image: docker:latest
environment:
REGISTRY_REPO: git.mifi.dev/mifi-holdings/shorty
DOCKER_API_VERSION: '1.43'
DOCKER_BUILDKIT: '1'
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e
- echo "=== Building Docker image (BuildKit) ==="
- 'echo "Commit SHA: ${CI_COMMIT_SHA:0:8}"'
- 'echo "Registry repo: $REGISTRY_REPO"'
- |
build() {
docker build \
--progress=plain \
--tag $REGISTRY_REPO:${CI_COMMIT_SHA} \
--tag $REGISTRY_REPO:latest \
--label "git.commit=${CI_COMMIT_SHA}" \
--label "git.branch=${CI_COMMIT_BRANCH}" \
.
}
for attempt in 1 2 3; do
echo "Build attempt $attempt/3"
if build; then
echo "✓ Docker image built successfully"
exit 0
fi
echo "Build attempt $attempt failed, retrying in 30s..."
sleep 30
done
echo "All build attempts failed"
exit 1
- name: Push to registry
image: docker:latest
environment:
DOCKER_API_VERSION: '1.43'
REGISTRY_URL: git.mifi.dev
REGISTRY_REPO: git.mifi.dev/mifi-holdings/shorty
REGISTRY_USERNAME:
from_secret: gitea_registry_username
REGISTRY_PASSWORD:
from_secret: gitea_package_token
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- set -e
- echo "=== Pushing to registry ==="
- 'echo "Registry: $REGISTRY_URL"'
- 'echo "Repository: $REGISTRY_REPO"'
- |
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_URL" \
-u "$REGISTRY_USERNAME" \
--password-stdin
- docker push $REGISTRY_REPO:${CI_COMMIT_SHA}
- docker push $REGISTRY_REPO:latest
- echo "✓ Images pushed successfully"
depends_on:
- Docker image build
- name: Trigger Portainer stack redeploy
image: curlimages/curl:latest
environment:
PORTAINER_WEBHOOK_URL:
from_secret: portainer_webhook_url
commands:
- set -e
- echo "=== Triggering Portainer stack redeploy ==="
- |
resp=$(curl -s -w "\n%{http_code}" -X POST "$PORTAINER_WEBHOOK_URL")
body=$(echo "$resp" | head -n -1)
code=$(echo "$resp" | tail -n 1)
if [ "$code" != "200" ] && [ "$code" != "204" ]; then
echo "Webhook failed (HTTP $code): $body"
exit 1
fi
echo "✓ Portainer redeploy triggered (HTTP $code)"
depends_on:
- Push to registry

104
README.md Normal file
View File

@@ -0,0 +1,104 @@
# Shorty — URL shortener + QR designer stack
Production-ready, self-hosted stack:
- **Kutt** for link shortening: short links at `https://mifi.me`, admin UI at `https://link.mifi.me`
- **QR Designer** at `https://qr.mifi.dev`: styled QR codes with optional Kutt shortening, logo upload, export (SVG, PNG, PDF). Protected by Traefik BasicAuth.
Designed for Docker/Portainer with Traefik. Uses **pnpm** everywhere; no Tailwind.
## Prerequisites
- **Traefik** with:
- External network `marina-net` (create with `docker network create marina-net` if needed)
- Cert resolver (e.g. `letsencrypt` or `lets-encrypt` — adjust labels in `docker-compose.yml` to match your Traefik)
- **DNS**: A records for `mifi.me`, `link.mifi.me`, `qr.mifi.dev` pointing to the host running Traefik
- **Bind mount paths** on the host (create if missing):
- `/mnt/config/docker/kutt/postgres` — Kutt Postgres data
- `/mnt/config/docker/kutt/redis` — Kutt Redis data
- `/mnt/config/docker/qr/db` — qr-api SQLite directory
- `/mnt/config/docker/qr/uploads` — qr-api uploads (logos)
## Kutt setup
1. Deploy the stack (see below). On first run, open `https://link.mifi.me` and create an admin account.
2. In Kutt admin: **Settings → API** (or **Account → API**), create an API key.
3. Set `KUTT_API_KEY` in the environment for **qr-api** (and optionally for local dev). The QR app uses this to shorten URLs via the backend; qr-api is not exposed publicly.
## Deploy (Portainer)
1. In Portainer: **Stacks → Add stack**.
2. Use the repo root `docker-compose.yml` (clone repo or paste content).
3. Set required env vars (at least):
- `DB_PASSWORD` — Postgres password for Kutt
- `JWT_SECRET` — Kutt JWT secret (generate a random string)
- `KUTT_API_KEY` — Kutt API key for qr-api (after creating it in Kutt UI)
4. Deploy. No ports are exposed; Traefik handles ingress.
## Env vars and .env.example
Copy `.env.example` to `.env` and set values for Docker Compose / production:
- **DB_PASSWORD** (required) — Postgres password for Kutt
- **JWT_SECRET** (required) — Kutt JWT secret (use a long random string)
- **KUTT_API_KEY** (optional) — Kutt API key for qr-api shorten feature (create in Kutt UI first)
For local dev inside the devcontainer, env vars for qr-api (`DB_PATH`, `UPLOADS_PATH`, `KUTT_API_KEY`, `QR_API_URL`) are set in `.devcontainer/devcontainer.json` so you dont need a `.env` file to run qr-api and qr-web with pnpm.
## Local run (Docker Compose)
From repo root, after copying `.env.example` to `.env` and setting values:
```bash
docker compose up -d
```
For local dev without Traefik, you can add a `ports` override for qr_web (e.g. `3000:3000`) and access the QR app at `http://localhost:3000`. Kutt would need its own ports if you want to test shortening locally.
## Development with Devcontainer
**Yes — run locally inside the devcontainer.** The devcontainer is the intended environment for development and testing.
1. Open the repo in VS Code/Cursor and use **Dev Containers: Reopen in Container** (or Codespaces).
2. `pnpm install` runs automatically. Env vars for qr-api are set in `devcontainer.json` (`DB_PATH`, `UPLOADS_PATH`, `KUTT_API_KEY`, `QR_API_URL`) so you can run qr-api and qr-web without a `.env` file.
3. In the container, start the apps:
- **qr-api:** `pnpm --filter qr-api dev` (listens on 8080)
- **qr-web:** `pnpm --filter qr-web dev` (listens on 3000)
4. Open the forwarded ports (3000 = qr-web, 8080 = qr-api). Data and uploads are stored under `.data/` in the repo (gitignored).
5. For full stack (Kutt + qr-api + qr-web in Docker), run `docker compose up` from the **host** (or from inside the container if Docker-in-Docker is enabled). Set `DB_PASSWORD`, `JWT_SECRET`, and optionally `KUTT_API_KEY` in `.env` for that.
Ports 3000 and 8080 are forwarded by the devcontainer.
## Repo structure
- `docker-compose.yml` — Root compose for Portainer (Kutt + qr-api + qr-web, Traefik labels).
- `qr-api/` — Node/TS Express API: SQLite projects, uploads, shorten proxy to Kutt. Not exposed via Traefik.
- `qr-web/` — Next.js (App Router) + Mantine QR designer; proxies all API calls to qr-api server-side.
- `.woodpecker.yml` — CI: lint-and-test on PR/push to main; manual deploy with `depends_on` lint-and-test.
- `.devcontainer/` — Devcontainer for local dev.
## Security
- **qr-api** is only on the `backend` network; only qr-web (and other backend services) can reach it. No Traefik router for qr-api.
- **qr-web** is exposed at `qr.mifi.dev` with Traefik BasicAuth (htpasswd user `mifi`). Set your own password and update the middleware label if needed.
- **Kutt** is public at `mifi.me` and `link.mifi.me`; use Kutts own auth (admin account, API keys).
## qr-border-plugin (optional)
The QR designer uses **qr-code-styling** for dots, corners, colors, and error correction. The optional **qr-border-plugin** (from [lefe.dev marketplace](https://lefe.dev/marketplace/qr-border-plugin)) adds border styling but depends on `@lefe-dev/lefe-verify-license`, which may involve licensing/watermark behavior. This stack uses qr-code-styling only by default; you can add qr-border-plugin from npm or GitHub if desired and document any license terms.
## Switching to prebuilt images (CI/CD)
In `.woodpecker.yml`, the deploy pipeline has placeholder steps. To use prebuilt images:
1. Build `qr-api` and `qr-web` in CI (e.g. `docker build -t $REGISTRY/shorty/qr-api:$CI_COMMIT_SHA ./qr-api`).
2. Push to your registry; set `REGISTRY` and `IMAGE_PREFIX` (or equivalent) as secrets.
3. In `docker-compose.yml`, replace `build: context: ./qr-api` with `image: $REGISTRY/shorty/qr-api:$TAG` (use env or a compose override for TAG).
## Code style and tooling
- **TypeScript** everywhere.
- **Prettier:** tabWidth 4, spaces (no tabs), singleQuote, trailingComma all, semi.
- **ESLint** for TS/React/Next in qr-web; shared root config for qr-api.
- **pnpm** only; `pnpm-lock.yaml` is the single lockfile (no package-lock.json or yarn.lock).
- **Tests:** Vitest (qr-api and qr-web).

112
docker-compose.yml Normal file
View File

@@ -0,0 +1,112 @@
services:
kutt_db:
image: postgres:16-alpine
restart: unless-stopped
networks:
- backend
volumes:
- /mnt/config/docker/kutt/postgres:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${DB_USER:-kutt}
POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD}
POSTGRES_DB: ${DB_NAME:-kutt}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-kutt} -d ${DB_NAME:-kutt}"]
interval: 10s
timeout: 5s
retries: 5
kutt_redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- backend
volumes:
- /mnt/config/docker/kutt/redis:/data
command: redis-server --appendonly yes
kutt:
image: kutt/kutt:latest
restart: unless-stopped
networks:
- marina-net
- backend
depends_on:
kutt_db:
condition: service_healthy
kutt_redis:
condition: service_started
environment:
DB_CLIENT: pg
DB_HOST: kutt_db
DB_PORT: "5432"
DB_USER: ${DB_USER:-kutt}
DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD}
DB_NAME: ${DB_NAME:-kutt}
REDIS_ENABLED: "true"
REDIS_HOST: kutt_redis
REDIS_PORT: "6379"
DEFAULT_DOMAIN: mifi.me
NODE_ENV: production
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET}
labels:
- "traefik.enable=true"
- "docker.network=marina-net"
- "traefik.http.routers.kutt-mifi.rule=Host(`mifi.me`)"
- "traefik.http.routers.kutt-mifi.entrypoints=websecure"
- "traefik.http.routers.kutt-mifi.tls.certresolver=letsencrypt"
- "traefik.http.routers.kutt-mifi.service=kutt-short"
- "traefik.http.services.kutt-short.loadbalancer.server.port=3000"
- "traefik.http.routers.kutt-link.rule=Host(`link.mifi.me`)"
- "traefik.http.routers.kutt-link.entrypoints=websecure"
- "traefik.http.routers.kutt-link.tls.certresolver=letsencrypt"
- "traefik.http.routers.kutt-link.service=kutt"
- "traefik.http.services.kutt.loadbalancer.server.port=3000"
qr_api:
build:
context: ./qr-api
dockerfile: Dockerfile
container_name: qr_api
restart: unless-stopped
networks:
- backend
volumes:
- /mnt/config/docker/qr/db:/data
- /mnt/config/docker/qr/uploads:/uploads
environment:
PORT: "8080"
DB_PATH: /data/db.sqlite
UPLOADS_PATH: /uploads
KUTT_API_KEY: ${KUTT_API_KEY:-}
KUTT_BASE_URL: http://kutt:3000
SHORT_DOMAIN: https://mifi.me
qr_web:
build:
context: ./qr-web
dockerfile: Dockerfile
restart: unless-stopped
networks:
- marina-net
- backend
depends_on:
- qr_api
environment:
QR_API_URL: http://qr_api:8080
labels:
- "traefik.enable=true"
- "docker.network=marina-net"
- "traefik.http.routers.qr-web.rule=Host(`qr.mifi.dev`)"
- "traefik.http.routers.qr-web.entrypoints=websecure"
- "traefik.http.routers.qr-web.tls.certresolver=letsencrypt"
- "traefik.http.routers.qr-web.service=qr-web"
- "traefik.http.routers.qr-web.middlewares=qr-web-basicauth"
- "traefik.http.middlewares.qr-web-basicauth.basicauth.users=mifi:$$2y$$05$$TS20fkfrmJ3MLc.cgfM6OcuowOstcy/2DTOq0YfirUDU3b0vtNz."
- "traefik.http.services.qr-web.loadbalancer.server.port=3000"
networks:
marina-net:
external: true
backend:
driver: bridge

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "shorty",
"version": "0.0.9",
"private": true,
"type": "module",
"scripts": {
"lint": "pnpm -r run lint",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "pnpm -r run test"
},
"devDependencies": {
"prettier": "^3.4.2"
},
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@10.28.2",
"repository": {
"type": "git",
"url": "https://git.mifi.dev/mifi-holdings/shorty.git"
}
}

7786
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- "qr-api"
- "qr-web"

27
qr-api/.dockerignore Normal file
View File

@@ -0,0 +1,27 @@
# Dependencies (reinstalled in image)
node_modules
.pnpm-store
# Build output (rebuilt in image)
dist
# Dev and test
coverage
*.test.ts
*.spec.ts
vitest.config.ts
.env
.env.*
!.env.example
# Git and IDE
.git
.gitignore
.dockerignore
*.md
Dockerfile
# Data (mounted at runtime)
*.sqlite
.data
uploads

17
qr-api/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install
COPY . .
RUN pnpm run build
FROM node:20-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
ENV NODE_ENV=production
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --prod
COPY --from=builder /app/dist ./dist
EXPOSE 8080
CMD ["node", "dist/index.js"]

40
qr-api/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "qr-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"lint": "eslint src --ext .ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"better-sqlite3": "^11.6.0",
"cors": "^2.8.5",
"express": "^4.21.1",
"multer": "^1.4.5-lts.1",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"vitest": "^2.1.6"
},
"engines": {
"node": ">=20"
}
}

65
qr-api/src/db.test.ts Normal file
View File

@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import {
initDb,
createProject,
listProjects,
getProject,
updateProject,
deleteProject,
} from './db.js';
const testEnv = {
DB_PATH: ':memory:',
UPLOADS_PATH: '/tmp',
PORT: 8080,
KUTT_BASE_URL: 'http://kutt:3000',
SHORT_DOMAIN: 'https://mifi.me',
};
describe('db', () => {
let db: Database.Database;
beforeEach(() => {
db = initDb(testEnv as Parameters<typeof initDb>[0]);
});
it('creates and gets a project', () => {
const p = createProject(db, { name: 'Test', originalUrl: 'https://example.com' });
expect(p.id).toBeDefined();
expect(p.name).toBe('Test');
expect(p.originalUrl).toBe('https://example.com');
const got = getProject(db, p.id);
expect(got?.name).toBe('Test');
});
it('lists projects by updatedAt desc', async () => {
createProject(db, { name: 'A' });
await new Promise((r) => setTimeout(r, 2));
createProject(db, { name: 'B' });
const list = listProjects(db);
expect(list.length).toBe(2);
expect(list[0].name).toBe('B');
expect(list[1].name).toBe('A');
});
it('updates a project', () => {
const p = createProject(db, { name: 'Old' });
const updated = updateProject(db, p.id, { name: 'New', recipeJson: '{"x":1}' });
expect(updated?.name).toBe('New');
expect(updated?.recipeJson).toBe('{"x":1}');
});
it('deletes a project', () => {
const p = createProject(db, { name: 'Del' });
const deleted = deleteProject(db, p.id);
expect(deleted).toBe(true);
expect(getProject(db, p.id)).toBeNull();
});
it('returns null for missing project', () => {
expect(getProject(db, '00000000-0000-0000-0000-000000000000')).toBeNull();
expect(updateProject(db, '00000000-0000-0000-0000-000000000000', { name: 'X' })).toBeNull();
expect(deleteProject(db, '00000000-0000-0000-0000-000000000000')).toBe(false);
});
});

160
qr-api/src/db.ts Normal file
View File

@@ -0,0 +1,160 @@
import Database from 'better-sqlite3';
import { randomUUID } from 'crypto';
import type { Env } from './env.js';
export interface Project {
id: string;
name: string;
createdAt: string;
updatedAt: string;
originalUrl: string;
shortenEnabled: number;
shortUrl: string | null;
recipeJson: string;
logoFilename: string | null;
folderId: string | null;
}
export interface Folder {
id: string;
name: string;
sortOrder: number;
}
export function initDb(env: Env): Database.Database {
const db = new Database(env.DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT 'Untitled QR',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
originalUrl TEXT NOT NULL DEFAULT '',
shortenEnabled INTEGER NOT NULL DEFAULT 0,
shortUrl TEXT,
recipeJson TEXT NOT NULL DEFAULT '{}',
logoFilename TEXT,
folderId TEXT
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT 'Folder',
sortOrder INTEGER NOT NULL DEFAULT 0
)
`);
try {
db.exec(`ALTER TABLE projects ADD COLUMN folderId TEXT`);
} catch {
// column already exists (existing DB)
}
return db;
}
export function createProject(
db: Database.Database,
data: Partial<Omit<Project, 'id' | 'createdAt' | 'updatedAt'>>,
): Project {
const id = randomUUID();
const now = new Date().toISOString();
const name = data.name ?? 'Untitled QR';
const originalUrl = data.originalUrl ?? '';
const shortenEnabled = data.shortenEnabled ?? 0;
const shortUrl = data.shortUrl ?? null;
const recipeJson = data.recipeJson ?? '{}';
const logoFilename = data.logoFilename ?? null;
const folderId = data.folderId ?? null;
db.prepare(
`INSERT INTO projects (id, name, createdAt, updatedAt, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(id, name, now, now, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId);
return getProject(db, id)!;
}
export function listProjects(db: Database.Database): Omit<Project, 'recipeJson' | 'originalUrl' | 'shortUrl' | 'shortenEnabled'>[] {
const rows = db.prepare(
'SELECT id, name, createdAt, updatedAt, logoFilename, folderId FROM projects ORDER BY updatedAt DESC, createdAt DESC, id DESC',
).all() as Array<{ id: string; name: string; createdAt: string; updatedAt: string; logoFilename: string | null; folderId: string | null }>;
return rows;
}
export function getProject(db: Database.Database, id: string): Project | null {
const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as Project | undefined;
return row ?? null;
}
export function updateProject(
db: Database.Database,
id: string,
data: Partial<Omit<Project, 'id' | 'createdAt'>>,
): Project | null {
const existing = getProject(db, id);
if (!existing) return null;
const updatedAt = new Date().toISOString();
const name = data.name ?? existing.name;
const originalUrl = data.originalUrl ?? existing.originalUrl;
const shortenEnabled = data.shortenEnabled ?? existing.shortenEnabled;
const shortUrl = data.shortUrl !== undefined ? data.shortUrl : existing.shortUrl;
const recipeJson = data.recipeJson ?? existing.recipeJson;
const logoFilename = data.logoFilename !== undefined ? data.logoFilename : existing.logoFilename;
const folderId = data.folderId !== undefined ? data.folderId : existing.folderId;
db.prepare(
`UPDATE projects SET name = ?, updatedAt = ?, originalUrl = ?, shortenEnabled = ?, shortUrl = ?, recipeJson = ?, logoFilename = ?, folderId = ? WHERE id = ?`,
).run(name, updatedAt, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId, id);
return getProject(db, id);
}
export function deleteProject(db: Database.Database, id: string): boolean {
const result = db.prepare('DELETE FROM projects WHERE id = ?').run(id);
return result.changes > 0;
}
export function listFolders(db: Database.Database): Folder[] {
const rows = db.prepare(
'SELECT id, name, sortOrder FROM folders ORDER BY sortOrder ASC, name ASC',
).all() as Folder[];
return rows;
}
export function createFolder(
db: Database.Database,
data: { name?: string; sortOrder?: number },
): Folder {
const id = randomUUID();
const name = data.name ?? 'Folder';
const sortOrder = data.sortOrder ?? listFolders(db).length;
db.prepare(
'INSERT INTO folders (id, name, sortOrder) VALUES (?, ?, ?)',
).run(id, name, sortOrder);
return { id, name, sortOrder };
}
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;
return row ?? null;
}
export function updateFolder(
db: Database.Database,
id: string,
data: Partial<Pick<Folder, 'name' | 'sortOrder'>>,
): Folder | null {
const existing = getFolder(db, id);
if (!existing) return null;
const name = data.name ?? existing.name;
const sortOrder = data.sortOrder ?? existing.sortOrder;
db.prepare('UPDATE folders SET name = ?, sortOrder = ? WHERE id = ?').run(name, sortOrder, id);
return getFolder(db, id);
}
export function deleteFolder(db: Database.Database, id: string): boolean {
db.prepare('UPDATE projects SET folderId = NULL WHERE folderId = ?').run(id);
const result = db.prepare('DELETE FROM folders WHERE id = ?').run(id);
return result.changes > 0;
}

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

@@ -0,0 +1,20 @@
import { z } from 'zod';
const envSchema = z.object({
PORT: z.coerce.number().default(8080),
DB_PATH: z.string().default('/data/db.sqlite'),
UPLOADS_PATH: z.string().default('/uploads'),
KUTT_API_KEY: z.string().optional(),
KUTT_BASE_URL: z.string().url().default('http://kutt:3000'),
SHORT_DOMAIN: z.string().default('https://mifi.me'),
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error('Invalid env: ' + parsed.error.message);
}
return parsed.data;
}

55
qr-api/src/index.ts Normal file
View File

@@ -0,0 +1,55 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs';
import pino from 'pino';
import { loadEnv } from './env.js';
import { initDb } from './db.js';
import { projectsRouter } from './routes/projects.js';
import { foldersRouter } from './routes/folders.js';
import { uploadsRouter } from './routes/uploads.js';
import { shortenRouter } from './routes/shorten.js';
const env = loadEnv();
const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });
// Ensure data dirs exist
const dataDir = path.dirname(env.DB_PATH);
const uploadsDir = env.UPLOADS_PATH;
for (const dir of [dataDir, uploadsDir]) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
logger.info({ dir }, 'Created directory');
}
}
const db = initDb(env);
const app = express();
app.use(cors());
app.use(express.json());
const baseUrl = ''; // relative; Next.js proxy will use same origin for /api
app.use('/projects', projectsRouter(db, baseUrl));
app.use('/folders', foldersRouter(db));
app.use('/uploads', uploadsRouter(env, baseUrl));
app.use('/shorten', shortenRouter(env));
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const msg = err.message ?? '';
if (err.name === 'MulterError' || msg.includes('image files') || msg.includes('file size')) {
return res.status(400).json({ error: msg || 'Invalid upload' });
}
logger.error({ err }, 'Unhandled error');
res.status(500).json({ error: 'Internal server error' });
});
const port = env.PORT;
app.listen(port, () => {
logger.info({ port }, 'qr-api listening');
});

View File

@@ -0,0 +1,86 @@
import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import type { Database } from 'better-sqlite3';
import {
listFolders,
createFolder,
getFolder,
updateFolder,
deleteFolder,
} from '../db.js';
const createBodySchema = z.object({
name: z.string().optional(),
sortOrder: z.number().optional(),
});
const updateBodySchema = createBodySchema.partial();
const idParamSchema = z.object({ id: z.string().uuid() });
export function foldersRouter(db: Database) {
const router = Router();
router.get('/', (_req: Request, res: Response) => {
try {
const folders = listFolders(db);
return res.json(folders);
} catch (e) {
return res.status(500).json({ error: String(e) });
}
});
router.post('/', (req: Request, res: Response) => {
try {
const parsed = createBodySchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.message });
}
const folder = createFolder(db, parsed.data);
return res.status(201).json(folder);
} catch (e) {
return res.status(500).json({ error: String(e) });
}
});
router.get('/:id', (req: Request, res: Response) => {
const parsed = idParamSchema.safeParse(req.params);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid id' });
}
const folder = getFolder(db, parsed.data.id);
if (!folder) {
return res.status(404).json({ error: 'Not found' });
}
return res.json(folder);
});
router.put('/:id', (req: Request, res: Response) => {
const paramParsed = idParamSchema.safeParse(req.params);
if (!paramParsed.success) {
return res.status(400).json({ error: 'Invalid id' });
}
const bodyParsed = updateBodySchema.safeParse(req.body);
if (!bodyParsed.success) {
return res.status(400).json({ error: bodyParsed.error.message });
}
const folder = updateFolder(db, paramParsed.data.id, bodyParsed.data);
if (!folder) {
return res.status(404).json({ error: 'Not found' });
}
return res.json(folder);
});
router.delete('/:id', (req: Request, res: Response) => {
const parsed = idParamSchema.safeParse(req.params);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid id' });
}
const deleted = deleteFolder(db, parsed.data.id);
if (!deleted) {
return res.status(404).json({ error: 'Not found' });
}
return res.status(204).send();
});
return router;
}

View File

@@ -0,0 +1,125 @@
import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import type { Database } from 'better-sqlite3';
import {
createProject,
getProject,
listProjects,
updateProject,
deleteProject,
} from '../db.js';
const createBodySchema = z.object({
name: z.string().optional(),
originalUrl: z.string().optional(),
shortenEnabled: z.boolean().optional(),
shortUrl: z.string().nullable().optional(),
recipeJson: z.string().optional(),
logoFilename: z.string().nullable().optional(),
folderId: z.string().uuid().nullable().optional(),
});
const updateBodySchema = createBodySchema.partial();
const idParamSchema = z.object({ id: z.string().uuid() });
export function projectsRouter(db: Database, baseUrl: string) {
const router = Router();
const toJson = (p: ReturnType<typeof getProject>) =>
p
? {
...p,
shortenEnabled: Boolean(p.shortenEnabled),
logoUrl: p.logoFilename ? `${baseUrl}/uploads/${p.logoFilename}` : null,
}
: null;
router.post('/', (req: Request, res: Response) => {
try {
const parsed = createBodySchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.message });
}
const data = parsed.data;
const project = createProject(db, {
name: data.name,
originalUrl: data.originalUrl,
shortenEnabled: data.shortenEnabled ? 1 : 0,
shortUrl: data.shortUrl ?? null,
recipeJson: data.recipeJson ?? '{}',
logoFilename: data.logoFilename ?? null,
folderId: data.folderId ?? null,
});
return res.status(201).json(toJson(project));
} catch (e) {
return res.status(500).json({ error: String(e) });
}
});
router.get('/', (_req: Request, res: Response) => {
try {
const list = listProjects(db);
const items = list.map((p) => ({
id: p.id,
name: p.name,
updatedAt: p.updatedAt,
logoUrl: p.logoFilename ? `${baseUrl}/uploads/${p.logoFilename}` : null,
folderId: p.folderId ?? null,
}));
return res.json(items);
} catch (e) {
return res.status(500).json({ error: String(e) });
}
});
router.get('/:id', (req: Request, res: Response) => {
const parsed = idParamSchema.safeParse(req.params);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid id' });
}
const project = getProject(db, parsed.data.id);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
return res.json(toJson(project));
});
router.put('/:id', (req: Request, res: Response) => {
const paramParsed = idParamSchema.safeParse(req.params);
if (!paramParsed.success) {
return res.status(400).json({ error: 'Invalid id' });
}
const bodyParsed = updateBodySchema.safeParse(req.body);
if (!bodyParsed.success) {
return res.status(400).json({ error: bodyParsed.error.message });
}
const data = bodyParsed.data;
const project = updateProject(db, paramParsed.data.id, {
name: data.name,
originalUrl: data.originalUrl,
shortenEnabled: data.shortenEnabled !== undefined ? (data.shortenEnabled ? 1 : 0) : undefined,
shortUrl: data.shortUrl,
recipeJson: data.recipeJson,
logoFilename: data.logoFilename,
folderId: data.folderId,
});
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
return res.json(toJson(project));
});
router.delete('/:id', (req: Request, res: Response) => {
const parsed = idParamSchema.safeParse(req.params);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid id' });
}
const deleted = deleteProject(db, parsed.data.id);
if (!deleted) {
return res.status(404).json({ error: 'Project not found' });
}
return res.status(204).send();
});
return router;
}

View File

@@ -0,0 +1,32 @@
import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import type { Env } from '../env.js';
import { shortenUrl } from '../shorten.js';
const bodySchema = z.object({
targetUrl: z.string().url(),
customSlug: z.string().optional(),
});
export function shortenRouter(env: Env) {
const router = Router();
router.post('/', async (req: Request, res: Response) => {
const parsed = bodySchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.message });
}
try {
const result = await shortenUrl(env, parsed.data);
return res.json(result);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('KUTT_API_KEY')) {
return res.status(503).json({ error: msg });
}
return res.status(502).json({ error: msg });
}
});
return router;
}

View File

@@ -0,0 +1,35 @@
import { Router, type Request, type Response } from 'express';
import path from 'path';
import fs from 'fs';
import type { Env } from '../env.js';
import { createMulter } from '../upload.js';
export function uploadsRouter(env: Env, baseUrl: string) {
const router = Router();
const upload = createMulter(env);
router.post('/logo', upload.single('file'), (req: Request, res: Response) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const filename = req.file.filename;
const url = `${baseUrl}/uploads/${filename}`;
return res.json({ filename, url });
});
router.get('/:filename', (req: Request, res: Response) => {
const filename = req.params.filename;
if (!filename || filename.includes('..') || path.isAbsolute(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const filePath = path.join(env.UPLOADS_PATH, filename);
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
return res.status(404).json({ error: 'Not found' });
}
return res.sendFile(path.resolve(filePath), (err) => {
if (err) res.status(500).json({ error: String(err) });
});
});
return router;
}

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { shortenUrl } from './shorten.js';
const env = {
KUTT_API_KEY: 'test-key',
KUTT_BASE_URL: 'http://kutt:3000',
SHORT_DOMAIN: 'https://mifi.me',
DB_PATH: '/data/db.sqlite',
UPLOADS_PATH: '/uploads',
PORT: 8080,
};
describe('shortenUrl', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
it('calls Kutt API and returns shortUrl', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ link: 'https://mifi.me/abc' }),
});
const result = await shortenUrl(env as Parameters<typeof shortenUrl>[0], {
targetUrl: 'https://example.com',
});
expect(result.shortUrl).toBe('https://mifi.me/abc');
expect(fetch).toHaveBeenCalledWith(
'http://kutt:3000/api/v2/links',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' },
body: JSON.stringify({ target: 'https://example.com' }),
}),
);
});
it('sends customurl when customSlug provided', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ link: 'https://mifi.me/myslug' }),
});
await shortenUrl(env as Parameters<typeof shortenUrl>[0], {
targetUrl: 'https://example.com',
customSlug: 'myslug',
});
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: JSON.stringify({ target: 'https://example.com', customurl: 'myslug' }),
}),
);
});
it('throws when KUTT_API_KEY is missing', async () => {
await expect(
shortenUrl({ ...env, KUTT_API_KEY: undefined } as Parameters<typeof shortenUrl>[0], {
targetUrl: 'https://example.com',
}),
).rejects.toThrow('KUTT_API_KEY');
});
it('throws when Kutt returns non-ok', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 400,
text: () => Promise.resolve('Bad request'),
});
await expect(
shortenUrl(env as Parameters<typeof shortenUrl>[0], { targetUrl: 'https://example.com' }),
).rejects.toThrow(/400/);
});
});

43
qr-api/src/shorten.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { Env } from './env.js';
export interface ShortenBody {
targetUrl: string;
customSlug?: string;
}
export interface ShortenResult {
shortUrl: string;
}
export async function shortenUrl(env: Env, body: ShortenBody): Promise<ShortenResult> {
if (!env.KUTT_API_KEY) {
throw new Error('KUTT_API_KEY is not configured');
}
const base = env.KUTT_BASE_URL.replace(/\/$/, '');
const res = await fetch(`${base}/api/v2/links`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': env.KUTT_API_KEY,
},
body: JSON.stringify({
target: body.targetUrl,
...(body.customSlug && { customurl: body.customSlug }),
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kutt API error ${res.status}: ${text}`);
}
const data = (await res.json()) as { link?: string; id?: string };
const link = data.link ?? (data.id ? `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${data.id}` : null);
if (!link) {
throw new Error('Kutt API did not return a short URL');
}
const shortUrl = link.startsWith('http') ? link : `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${link}`;
return { shortUrl };
}

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

@@ -0,0 +1,27 @@
import multer from 'multer';
import path from 'path';
import { randomUUID } from 'crypto';
import type { Env } from './env.js';
const IMAGE_MIME = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
export function createMulter(env: Env) {
return multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, env.UPLOADS_PATH),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname) || '.bin';
cb(null, `${randomUUID()}${ext}`);
},
}),
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (_req, file, cb) => {
if (IMAGE_MIME.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only image files (jpeg, png, gif, webp) are allowed'));
}
},
});
}

17
qr-api/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

8
qr-api/vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
environment: 'node',
},
});

30
qr-web/.dockerignore Normal file
View File

@@ -0,0 +1,30 @@
# Dependencies (reinstalled in image)
node_modules
.pnpm-store
# Build output (rebuilt in image)
.next
out
dist
# Dev and test
coverage
*.test.ts
*.test.tsx
*.spec.ts
*.spec.tsx
vitest.config.ts
.env
.env.*
!.env.example
# Git and IDE
.git
.gitignore
.dockerignore
*.md
Dockerfile
# Generated
next-env.d.ts
*.tsbuildinfo

9
qr-web/.eslintrc.cjs Normal file
View File

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

22
qr-web/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build
FROM node:20-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml* ./
RUN pnpm install --prod
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

6
qr-web/next-env.d.ts vendored Normal file
View File

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

8
qr-web/next.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactStrictMode: true,
output: 'standalone',
};
export default nextConfig;

45
qr-web/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "qr-web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@mantine/core": "^7.14.0",
"@tabler/icons-react": "^3.23.0",
"@mantine/dropzone": "^7.14.0",
"@mantine/hooks": "^7.14.0",
"next": "^15.0.3",
"pdf-lib": "^1.4.2",
"qr-code-styling": "^1.9.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.0.1",
"@types/node": "^22.9.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"eslint": "^9.15.0",
"eslint-config-next": "^15.0.3",
"eslint-config-prettier": "^9.1.0",
"jsdom": "^25.0.0",
"postcss": "^8.4.49",
"postcss-nesting": "^13.0.0",
"postcss-preset-env": "^10.0.0",
"typescript": "^5.6.3",
"vitest": "^2.1.6",
"@vitejs/plugin-react": "^4.3.4"
},
"engines": {
"node": ">=20"
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'postcss-nesting': {},
'postcss-preset-env': { stage: 2 },
},
};
export default config;

View File

@@ -0,0 +1,57 @@
const QR_API_URL = process.env.QR_API_URL || 'http://qr_api:8080';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const res = await fetch(`${QR_API_URL}/folders/${id}`, { cache: 'no-store' });
const data = await res.json();
if (!res.ok) {
return Response.json({ error: data?.error ?? 'Not found' }, { status: res.status });
}
return Response.json(data);
} catch (e) {
return Response.json({ error: String(e) }, { status: 502 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const body = await request.json();
const res = await fetch(`${QR_API_URL}/folders/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
}
return Response.json(data);
} catch (e) {
return Response.json({ error: String(e) }, { status: 502 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const res = await fetch(`${QR_API_URL}/folders/${id}`, { method: 'DELETE' });
if (res.status === 204) {
return new Response(null, { status: 204 });
}
const data = await res.json();
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
} catch (e) {
return Response.json({ error: String(e) }, { status: 502 });
}
}

View File

@@ -0,0 +1,32 @@
const QR_API_URL = process.env.QR_API_URL || 'http://qr_api:8080';
export async function GET() {
try {
const res = await fetch(`${QR_API_URL}/folders`, { cache: 'no-store' });
const data = await res.json();
if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
}
return Response.json(Array.isArray(data) ? data : data);
} catch (e) {
return Response.json({ error: String(e) }, { status: 502 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const res = await fetch(`${QR_API_URL}/folders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
}
return Response.json(data);
} catch (e) {
return Response.json({ error: String(e) }, { status: 502 });
}
}

View File

@@ -0,0 +1,63 @@
const QR_API_URL = process.env.QR_API_URL || 'http://qr_api:8080';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const res = await fetch(`${QR_API_URL}/projects/${id}`, { cache: 'no-store' });
const data = await res.json();
if (!res.ok) {
return Response.json({ error: data?.error ?? 'Not found' }, { status: res.status });
}
if (data?.logoUrl) {
data.logoUrl = data.logoUrl.replace(/^\/uploads\//, '/api/uploads/');
}
return Response.json(data);
} catch (e) {
return Response.json({ error: String(e) }, { status: 502 });
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const body = await request.json();
const res = await fetch(`${QR_API_URL}/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
}
if (data?.logoUrl) {
data.logoUrl = data.logoUrl.replace(/^\/uploads\//, '/api/uploads/');
}
return Response.json(data);
} catch (e) {
return Response.json({ error: String(e) }, { status: 502 });
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const res = await fetch(`${QR_API_URL}/projects/${id}`, { method: 'DELETE' });
if (res.status === 204) {
return new Response(null, { status: 204 });
}
const data = await res.json();
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
} catch (e) {
return Response.json({ error: String(e) }, { status: 502 });
}
}

View File

@@ -0,0 +1,39 @@
const QR_API_URL = process.env.QR_API_URL || 'http://qr_api:8080';
function rewriteLogoUrl(items: Array<{ logoUrl?: string | null }>) {
return items.map((item) => ({
...item,
logoUrl: item.logoUrl?.replace(/^\/uploads\//, '/api/uploads/') ?? null,
}));
}
export async function GET() {
try {
const res = await fetch(`${QR_API_URL}/projects`, { cache: 'no-store' });
const data = await res.json();
if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
}
return Response.json(Array.isArray(data) ? rewriteLogoUrl(data) : data);
} catch (e) {
return Response.json({ error: String(e) }, { status: 502 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const res = await fetch(`${QR_API_URL}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
}
return Response.json(data);
} catch (e) {
return Response.json({ error: String(e) }, { status: 502 });
}
}

View File

@@ -0,0 +1,19 @@
const QR_API_URL = process.env.QR_API_URL || 'http://qr_api:8080';
export async function POST(request: Request) {
try {
const body = await request.json();
const res = await fetch(`${QR_API_URL}/shorten`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
return Response.json({ error: data?.error ?? 'Shorten failed' }, { status: res.status });
}
return Response.json(data);
} catch (e) {
return Response.json({ error: String(e) }, { status: 502 });
}
}

View File

@@ -0,0 +1,12 @@
:root {
--app-bg: #0d1117;
--sidebar-bg: #161b22;
--border: #30363d;
}
body {
margin: 0;
background: var(--app-bg);
color: #e6edf3;
font-family: system-ui, -apple-system, sans-serif;
}

22
qr-web/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
'use client';
import { MantineProvider } from '@mantine/core';
import '@mantine/core/styles.css';
import '@mantine/dropzone/styles.css';
import './globals.css';
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<MantineProvider defaultColorScheme="dark">
{children}
</MantineProvider>
</body>
</html>
);
}

5
qr-web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/projects');
}

View File

@@ -0,0 +1,10 @@
import { Editor } from '@/components/Editor';
export default async function ProjectEditorPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <Editor id={id} />;
}

View File

@@ -0,0 +1,3 @@
.main {
background: var(--app-bg);
}

View File

@@ -0,0 +1,44 @@
'use client';
import { useEffect } from 'react';
import { AppShell } from '@mantine/core';
import { Sidebar } from '@/components/Sidebar';
import { ProjectsProvider, useProjects } from '@/contexts/ProjectsContext';
import classes from './layout.module.css';
function ProjectsLayoutInner({
children,
}: {
children: React.ReactNode;
}) {
const { refetch } = useProjects();
useEffect(() => {
refetch();
}, [refetch]);
return (
<AppShell
navbar={{ width: 280, breakpoint: 'sm' }}
padding="md"
classNames={{ main: classes.main }}
>
<AppShell.Navbar>
<Sidebar />
</AppShell.Navbar>
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
);
}
export default function ProjectsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ProjectsProvider>
<ProjectsLayoutInner>{children}</ProjectsLayoutInner>
</ProjectsProvider>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Center, Loader } from '@mantine/core';
export default function NewProjectPage() {
const router = useRouter();
useEffect(() => {
fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Untitled QR',
originalUrl: '',
shortenEnabled: false,
recipeJson: '{}',
}),
})
.then((r) => r.json())
.then((data) => {
if (data?.id) {
router.replace(`/projects/${data.id}`);
}
})
.catch(() => {});
}, [router]);
return (
<Center h={200}>
<Loader size="sm" />
</Center>
);
}

View File

@@ -0,0 +1,5 @@
import { ProjectsList } from '@/components/ProjectsList';
export default function ProjectsPage() {
return <ProjectsList />;
}

View File

@@ -0,0 +1,28 @@
.root {
display: flex;
gap: 24px;
padding: 16px;
max-width: 1200px;
margin: 0 auto;
}
.editor {
flex: 0 0 360px;
}
.preview {
flex: 1;
min-width: 0;
}
.paper {
background: var(--sidebar-bg);
border-color: var(--border);
}
.center {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}

View File

@@ -0,0 +1,840 @@
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import {
Stack,
TextInput,
Switch,
Text,
Loader,
Paper,
Group,
Select,
ColorInput,
Divider,
Alert,
Center,
SegmentedControl,
NumberInput,
Modal,
Button,
ActionIcon,
} from '@mantine/core';
import { IconLink, IconFileText, IconMail, IconPhone, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
import { useDebouncedCallback } from '@mantine/hooks';
import { QrPreview } from './QrPreview';
import { ExportPanel } from './ExportPanel';
import { useProjects } from '@/contexts/ProjectsContext';
import type { Project, RecipeOptions, ContentType, QrGradient } from '@/types/project';
import { makeGradient } from '@/lib/qrStylingOptions';
import classes from './Editor.module.css';
const CONTENT_TYPES: Array<{
value: ContentType;
label: React.ReactNode;
placeholder: string;
inputLabel: string;
validate: (value: string) => string | null;
}> = [
{
value: 'url',
label: (
<Center style={{ gap: 6 }}>
<IconLink size={18} />
<span>URL</span>
</Center>
),
placeholder: 'https://example.com',
inputLabel: 'Website address',
validate: (v) =>
!v.trim() ? 'Enter a URL' : /^https?:\/\/.+/i.test(v.trim()) ? null : 'URL must start with http:// or https://',
},
{
value: 'text',
label: (
<Center style={{ gap: 6 }}>
<IconFileText size={18} />
<span>Text</span>
</Center>
),
placeholder: 'Any text, message, or text-based data',
inputLabel: 'Text content',
validate: (v) => (!v.trim() ? 'Enter some text' : null),
},
{
value: 'email',
label: (
<Center style={{ gap: 6 }}>
<IconMail size={18} />
<span>Email</span>
</Center>
),
placeholder: 'name@example.com',
inputLabel: 'Email address',
validate: (v) => {
if (!v.trim()) return 'Enter an email address';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(v.trim()) ? null : 'Enter a valid email address';
},
},
{
value: 'phone',
label: (
<Center style={{ gap: 6 }}>
<IconPhone size={18} />
<span>Phone</span>
</Center>
),
placeholder: '+1 234 567 8900',
inputLabel: 'Phone number',
validate: (v) => {
if (!v.trim()) return 'Enter a phone number';
const digits = v.replace(/\D/g, '');
return digits.length >= 7 && digits.length <= 15 ? null : 'Enter a valid phone number (715 digits)';
},
},
];
function inferContentType(content: string, current?: ContentType): ContentType {
if (current && CONTENT_TYPES.some((t) => t.value === current)) return current;
const t = content.trim();
if (/^https?:\/\//i.test(t)) return 'url';
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) return 'email';
if (/^[\d\s+()-]{7,}$/.test(t)) return 'phone';
return 'text';
}
const DOT_TYPES = [
{ value: 'square', label: 'Square' },
{ value: 'rounded', label: 'Rounded' },
{ value: 'dots', label: 'Dots' },
{ value: 'classy', label: 'Classy' },
{ value: 'classy-rounded', label: 'Classy Rounded' },
{ value: 'extra-rounded', label: 'Extra Rounded' },
];
const CORNER_TYPES = [
{ value: 'square', label: 'Square' },
{ value: 'dot', label: 'Dot' },
{ value: 'extra-rounded', label: 'Extra Rounded' },
];
const ERROR_LEVELS = [
{ value: 'L', label: 'L (7%)' },
{ value: 'M', label: 'M (15%)' },
{ value: 'Q', label: 'Q (25%)' },
{ value: 'H', label: 'H (30%)' },
];
interface EditorProps {
id: string;
}
export function Editor({ id }: EditorProps) {
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
const [contentTouched, setContentTouched] = useState(false);
const pendingRef = useRef<Partial<Project> | null>(null);
const fetchProject = useCallback(() => {
fetch(`/api/projects/${id}`)
.then((r) => {
if (!r.ok) throw new Error('Not found');
return r.json();
})
.then(setProject)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
fetchProject();
}, [fetchProject]);
const router = useRouter();
const { updateProjectInList, removeProjectFromList } = useProjects();
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const saveProject = useCallback(
(patch: Partial<Project>) => {
if (!id) return;
setSaving(true);
fetch(`/api/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
.then((r) => r.json())
.then((data) => {
setProject((prev) => (prev ? { ...prev, ...data } : data));
setLastSaved(new Date());
updateProjectInList(id, {
name: data.name,
updatedAt: data.updatedAt,
logoUrl: data.logoUrl ?? undefined,
folderId: data.folderId ?? undefined,
});
})
.catch(() => {})
.finally(() => {
setSaving(false);
pendingRef.current = null;
});
},
[id, updateProjectInList],
);
const debouncedSave = useDebouncedCallback((patch: Partial<Project>) => {
saveProject(patch);
}, 600);
const updateProject = useCallback(
(patch: Partial<Project>) => {
setProject((prev) => (prev ? { ...prev, ...patch } : null));
pendingRef.current = { ...pendingRef.current, ...patch };
debouncedSave({ ...pendingRef.current });
},
[debouncedSave],
);
const handleDeleteProject = useCallback(() => {
if (!id) return;
fetch(`/api/projects/${id}`, { method: 'DELETE' })
.then((r) => {
if (r.status === 204 || r.ok) {
removeProjectFromList(id);
router.push('/projects');
}
})
.finally(() => setDeleteConfirmOpen(false));
}, [id, removeProjectFromList, router]);
const handleShorten = useCallback(() => {
if (!project?.originalUrl?.trim()) return;
fetch('/api/shorten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetUrl: project.originalUrl }),
})
.then((r) => r.json())
.then((data) => {
if (data?.shortUrl) {
updateProject({
shortUrl: data.shortUrl,
shortenEnabled: true,
recipeJson: (() => {
try {
const recipe = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
recipe.data = data.shortUrl;
return JSON.stringify(recipe);
} catch {
return JSON.stringify({ ...project, data: data.shortUrl });
}
})(),
});
}
})
.catch(() => {});
}, [project, updateProject]);
const handleLogoUpload = useCallback(
(files: File[]) => {
const file = files[0];
if (!file || !id) return;
const form = new FormData();
form.append('file', file);
fetch('/api/uploads/logo', {
method: 'POST',
body: form,
})
.then((r) => r.json())
.then((data) => {
if (data?.filename) {
updateProject({
logoFilename: data.filename,
logoUrl: `/api/uploads/${data.filename}`,
});
}
})
.catch(() => {});
},
[id, updateProject],
);
const setContentType = useCallback(
(type: ContentType) => {
if (!project) return;
setContentTouched(false);
try {
const r = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
r.contentType = type;
const patch: Partial<Project> = { recipeJson: JSON.stringify(r) };
if (type !== 'url' && (project.shortenEnabled || project.shortUrl)) {
patch.shortenEnabled = false;
patch.shortUrl = null;
r.data = (project.originalUrl ?? '') || undefined;
}
updateProject(patch);
} catch {
updateProject({});
}
},
[project, updateProject],
);
const setContent = useCallback(
(value: string) => {
if (!project) return;
const content = project.originalUrl ?? '';
let recipe: RecipeOptions = {};
try {
recipe = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
} catch {
recipe = {};
}
const contentType = inferContentType(content, recipe.contentType);
try {
const r = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
r.contentType = contentType;
if (contentType === 'url' && project.shortenEnabled && project.shortUrl) {
r.data = project.shortUrl;
} else {
r.data = value || undefined;
}
const patch: Partial<Project> = {
originalUrl: value,
recipeJson: JSON.stringify(r),
};
if (contentType !== 'url' && (project.shortenEnabled || project.shortUrl)) {
patch.shortenEnabled = false;
patch.shortUrl = null;
r.data = value || undefined;
}
updateProject(patch);
} catch {
updateProject({
originalUrl: value,
...(contentType !== 'url' && {
shortenEnabled: false,
shortUrl: null,
}),
});
}
},
[project, updateProject],
);
if (loading) {
return (
<Center className={classes.center}>
<Loader size="sm" />
</Center>
);
}
if (error || !project) {
return (
<Alert color="red" title="Error">
{error ?? 'Project not found'}
</Alert>
);
}
let recipe: RecipeOptions = {};
try {
recipe = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
} catch {
recipe = {};
}
const content = project.originalUrl ?? '';
const contentType = inferContentType(content, recipe.contentType);
const typeConfig = CONTENT_TYPES.find((t) => t.value === contentType) ?? CONTENT_TYPES[0];
const contentError = contentTouched ? typeConfig.validate(content) : null;
const isUrl = contentType === 'url';
const qrData =
isUrl && project.shortenEnabled && project.shortUrl
? project.shortUrl
: content || ' ';
return (
<div className={classes.root}>
<div className={classes.editor}>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{saving ? 'Saving…' : lastSaved ? `Saved ${lastSaved.toLocaleTimeString()}` : ''}
</Text>
<ActionIcon
size="sm"
variant="subtle"
color="red"
onClick={() => setDeleteConfirmOpen(true)}
aria-label="Delete project"
>
<IconTrash size={16} />
</ActionIcon>
</Group>
<TextInput
label="Project name"
placeholder="Untitled QR"
value={project.name}
onChange={(e) => updateProject({ name: e.target.value })}
/>
<Text size="sm" fw={500}>
Content type
</Text>
<SegmentedControl
value={contentType}
onChange={(v) => setContentType(v as ContentType)}
data={CONTENT_TYPES.map((t) => ({ value: t.value, label: t.label }))}
fullWidth
/>
<TextInput
label={typeConfig.inputLabel}
placeholder={typeConfig.placeholder}
description={
contentType === 'url'
? 'QR will open this link when scanned.'
: contentType === 'email'
? 'QR can open mailto: when scanned.'
: contentType === 'phone'
? 'QR can start a call when scanned.'
: undefined
}
value={project.originalUrl}
error={contentError}
onChange={(e) => setContent(e.target.value)}
onBlur={() => setContentTouched(true)}
/>
{isUrl && (
<>
<Group>
<Switch
label="Shorten with Kutt"
checked={project.shortenEnabled}
onChange={(e) => {
const checked = e.currentTarget.checked;
updateProject({ shortenEnabled: checked });
if (checked && project.originalUrl) handleShorten();
}}
/>
</Group>
{project.shortenEnabled && project.shortUrl && (
<Text size="sm" c="dimmed">
Short URL: {project.shortUrl}
</Text>
)}
</>
)}
<Divider label="Logo" />
<Dropzone
onDrop={handleLogoUpload}
accept={IMAGE_MIME_TYPE}
maxFiles={1}
maxSize={10 * 1024 * 1024}
>
<Text size="sm" c="dimmed" ta="center">
Drop logo image here (PNG, WebP, SVG, etc.)
</Text>
</Dropzone>
<Group grow>
<NumberInput
label="Logo size"
description="0.10.6"
min={0.1}
max={0.6}
step={0.05}
value={recipe.imageOptions?.imageSize ?? 0.4}
onChange={(n) => {
const r = { ...recipe };
const v = typeof n === 'string' ? parseFloat(n) : n;
r.imageOptions = {
...r.imageOptions,
imageSize: Number.isFinite(v) ? Math.max(0.1, Math.min(0.6, v)) : 0.4,
};
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<Switch
label="Hide dots behind logo"
checked={recipe.imageOptions?.hideBackgroundDots ?? true}
onChange={(e) => {
const r = { ...recipe };
r.imageOptions = {
...r.imageOptions,
hideBackgroundDots: e.currentTarget.checked,
};
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
</Group>
<Divider label="QR style" />
<Text size="sm" fw={500}>
Foreground
</Text>
<SegmentedControl
value={recipe.dotsOptions?.gradient ? 'gradient' : 'solid'}
onChange={(v) => {
const r = { ...recipe };
if (v === 'gradient') {
const g = makeGradient(
'linear',
recipe.dotsOptions?.color ?? '#000000',
'#444444',
0,
);
r.dotsOptions = { ...r.dotsOptions, gradient: g, color: undefined };
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g, color: undefined };
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g, color: undefined };
} else {
r.dotsOptions = { ...r.dotsOptions, gradient: undefined, color: recipe.dotsOptions?.color ?? '#000000' };
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: undefined, color: recipe.cornersSquareOptions?.color ?? '#000000' };
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: undefined, color: recipe.cornersSquareOptions?.color ?? '#000000' };
}
updateProject({ recipeJson: JSON.stringify(r) });
}}
data={[
{ value: 'solid', label: 'Solid' },
{ value: 'gradient', label: 'Gradient' },
]}
fullWidth
/>
{recipe.dotsOptions?.gradient ? (
<Stack gap="xs">
<Group grow>
<Select
label="Gradient type"
data={[
{ value: 'linear', label: 'Linear' },
{ value: 'radial', label: 'Radial' },
]}
value={recipe.dotsOptions.gradient.type}
onChange={(v) => {
const r = { ...recipe };
const g: QrGradient = {
...recipe.dotsOptions!.gradient!,
type: (v as 'linear' | 'radial') ?? 'linear',
};
r.dotsOptions = { ...r.dotsOptions, gradient: g };
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g };
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g };
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<NumberInput
label="Rotation (°)"
min={0}
max={360}
value={recipe.dotsOptions.gradient.rotation ?? 0}
onChange={(n) => {
const r = { ...recipe };
const g: QrGradient = {
...recipe.dotsOptions!.gradient!,
rotation: typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0,
};
r.dotsOptions = { ...r.dotsOptions, gradient: g };
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g };
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g };
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
</Group>
<Group grow>
<ColorInput
label="Start color"
value={recipe.dotsOptions.gradient.colorStops[0]?.color ?? '#000000'}
onChange={(c) => {
const r = { ...recipe };
const stops = [...(recipe.dotsOptions!.gradient!.colorStops || [])];
if (stops[0]) stops[0] = { ...stops[0], color: c };
else stops.unshift({ offset: 0, color: c });
const g: QrGradient = { ...recipe.dotsOptions!.gradient!, colorStops: stops };
r.dotsOptions = { ...r.dotsOptions, gradient: g };
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g };
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g };
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<ColorInput
label="End color"
value={recipe.dotsOptions.gradient.colorStops[1]?.color ?? recipe.dotsOptions.gradient.colorStops[0]?.color ?? '#444444'}
onChange={(c) => {
const r = { ...recipe };
const stops = [...(recipe.dotsOptions!.gradient!.colorStops || [])];
if (stops[1]) stops[1] = { ...stops[1], color: c };
else stops.push({ offset: 1, color: c });
const g: QrGradient = { ...recipe.dotsOptions!.gradient!, colorStops: stops };
r.dotsOptions = { ...r.dotsOptions, gradient: g };
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g };
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g };
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
</Group>
</Stack>
) : (
<ColorInput
label="Foreground color"
value={recipe.dotsOptions?.color ?? '#000000'}
onChange={(c) => {
const r = { ...recipe };
r.dotsOptions = { ...r.dotsOptions, color: c };
r.cornersSquareOptions = { ...r.cornersSquareOptions, color: c };
r.cornersDotOptions = { ...r.cornersDotOptions, color: c };
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
)}
<Text size="sm" fw={500}>
Background
</Text>
<SegmentedControl
value={recipe.backgroundOptions?.gradient ? 'gradient' : 'solid'}
onChange={(v) => {
const r = { ...recipe };
if (v === 'gradient') {
r.backgroundOptions = {
...r.backgroundOptions,
gradient: makeGradient(
'linear',
recipe.backgroundOptions?.color ?? '#ffffff',
'#e0e0e0',
0,
),
color: undefined,
};
} else {
r.backgroundOptions = {
...r.backgroundOptions,
gradient: undefined,
color: recipe.backgroundOptions?.color ?? '#ffffff',
};
}
updateProject({ recipeJson: JSON.stringify(r) });
}}
data={[
{ value: 'solid', label: 'Solid' },
{ value: 'gradient', label: 'Gradient' },
]}
fullWidth
/>
{recipe.backgroundOptions?.gradient ? (
<Stack gap="xs">
<Group grow>
<Select
label="Gradient type"
data={[
{ value: 'linear', label: 'Linear' },
{ value: 'radial', label: 'Radial' },
]}
value={recipe.backgroundOptions.gradient.type}
onChange={(v) => {
const r = { ...recipe };
r.backgroundOptions = {
...r.backgroundOptions,
gradient: {
...recipe.backgroundOptions!.gradient!,
type: (v as 'linear' | 'radial') ?? 'linear',
},
};
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<NumberInput
label="Rotation (°)"
min={0}
max={360}
value={recipe.backgroundOptions.gradient.rotation ?? 0}
onChange={(n) => {
const r = { ...recipe };
r.backgroundOptions = {
...r.backgroundOptions,
gradient: {
...recipe.backgroundOptions!.gradient!,
rotation: typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0,
},
};
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
</Group>
<Group grow>
<ColorInput
label="Start color"
value={recipe.backgroundOptions.gradient.colorStops[0]?.color ?? '#ffffff'}
onChange={(c) => {
const r = { ...recipe };
const stops = [...(recipe.backgroundOptions!.gradient!.colorStops || [])];
if (stops[0]) stops[0] = { ...stops[0], color: c };
else stops.unshift({ offset: 0, color: c });
r.backgroundOptions = {
...r.backgroundOptions,
gradient: { ...recipe.backgroundOptions!.gradient!, colorStops: stops },
};
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<ColorInput
label="End color"
value={recipe.backgroundOptions.gradient.colorStops[1]?.color ?? recipe.backgroundOptions.gradient.colorStops[0]?.color ?? '#e0e0e0'}
onChange={(c) => {
const r = { ...recipe };
const stops = [...(recipe.backgroundOptions!.gradient!.colorStops || [])];
if (stops[1]) stops[1] = { ...stops[1], color: c };
else stops.push({ offset: 1, color: c });
r.backgroundOptions = {
...r.backgroundOptions,
gradient: { ...recipe.backgroundOptions!.gradient!, colorStops: stops },
};
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
</Group>
</Stack>
) : (
<ColorInput
label="Background color"
value={recipe.backgroundOptions?.color ?? '#ffffff'}
onChange={(c) => {
const r = { ...recipe };
r.backgroundOptions = { ...r.backgroundOptions, color: c };
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
)}
<Select
label="Dot style"
data={DOT_TYPES}
value={recipe.dotsOptions?.type ?? 'square'}
onChange={(v) => {
const r = { ...recipe };
r.dotsOptions = { ...r.dotsOptions, type: v ?? 'square' };
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<Switch
label="Round dot size"
checked={recipe.dotsOptions?.roundSize ?? false}
onChange={(e) => {
const r = { ...recipe };
r.dotsOptions = { ...r.dotsOptions, roundSize: e.currentTarget.checked };
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<Select
label="Corner style"
data={CORNER_TYPES}
value={recipe.cornersSquareOptions?.type ?? 'square'}
onChange={(v) => {
const r = { ...recipe };
r.cornersSquareOptions = { ...r.cornersSquareOptions, type: v ?? 'square' };
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<Select
label="Corner dot style"
data={CORNER_TYPES}
value={recipe.cornersDotOptions?.type ?? recipe.cornersSquareOptions?.type ?? 'square'}
onChange={(v) => {
const r = { ...recipe };
r.cornersDotOptions = { ...r.cornersDotOptions, type: v ?? 'square' };
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<Select
label="Shape"
data={[
{ value: 'square', label: 'Square' },
{ value: 'circle', label: 'Circle' },
]}
value={recipe.shape ?? 'square'}
onChange={(v) => {
const r = { ...recipe };
r.shape = (v as 'square' | 'circle') ?? 'square';
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<Group grow>
<NumberInput
label="Margin"
min={0}
max={50}
value={recipe.margin ?? 0}
onChange={(n) => {
const r = { ...recipe };
r.margin = typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0;
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<NumberInput
label="Background round"
min={0}
max={100}
value={recipe.backgroundOptions?.round ?? 0}
onChange={(n) => {
const r = { ...recipe };
r.backgroundOptions = {
...r.backgroundOptions,
round: typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0,
};
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
</Group>
<Select
label="Error correction"
data={ERROR_LEVELS}
value={recipe.qrOptions?.errorCorrectionLevel ?? 'M'}
onChange={(v) => {
const r = { ...recipe };
r.qrOptions = { ...r.qrOptions, errorCorrectionLevel: v ?? 'M' };
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
</Stack>
</div>
<div className={classes.preview}>
<Paper p="md" withBorder className={classes.paper}>
<Text size="sm" fw={500} mb="sm">
Preview
</Text>
<QrPreview
data={qrData}
recipe={recipe}
logoUrl={project.logoUrl ?? undefined}
/>
</Paper>
<ExportPanel
data={qrData}
recipe={recipe}
logoUrl={project.logoUrl}
projectName={project.name}
/>
</div>
<Modal
opened={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
title="Delete project?"
centered
>
<Text size="sm" c="dimmed" mb="md">
This cannot be undone. The project &quot;{project.name || 'Untitled QR'}&quot; will be
permanently deleted.
</Text>
<Group justify="flex-end" gap="xs">
<Button variant="subtle" onClick={() => setDeleteConfirmOpen(false)}>
Cancel
</Button>
<Button color="red" onClick={handleDeleteProject}>
Delete project
</Button>
</Group>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,6 @@
.panel {
padding: 16px;
background: var(--sidebar-bg);
border: 1px solid var(--border);
border-radius: 8px;
}

View File

@@ -0,0 +1,113 @@
'use client';
import { useRef, useCallback } from 'react';
import { Button, Group, Stack, Text } from '@mantine/core';
import { IconDownload } from '@tabler/icons-react';
import QRCodeStyling from 'qr-code-styling';
import { PDFDocument } from 'pdf-lib';
import type { RecipeOptions } from '@/types/project';
import { buildQrStylingOptions } from '@/lib/qrStylingOptions';
import classes from './ExportPanel.module.css';
interface ExportPanelProps {
data: string;
recipe: RecipeOptions;
logoUrl?: string | null;
projectName?: string;
}
export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelProps) {
const qrRef = useRef<QRCodeStyling | null>(null);
const getQrInstance = useCallback(() => {
const opts = buildQrStylingOptions(recipe, {
width: 512,
height: 512,
data: data || ' ',
image: logoUrl || undefined,
});
if (qrRef.current) {
qrRef.current.update(opts as Parameters<QRCodeStyling['update']>[0]);
return qrRef.current;
}
const qr = new QRCodeStyling(opts as ConstructorParameters<typeof QRCodeStyling>[0]);
qrRef.current = qr;
return qr;
}, [data, recipe, logoUrl]);
const handleSvg = useCallback(async () => {
const qr = getQrInstance();
const blob = await qr.getRawData('svg');
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `qr-${projectName || 'export'}.svg`.replace(/[^a-z0-9.-]/gi, '-');
a.click();
URL.revokeObjectURL(url);
}, [getQrInstance, projectName]);
const handlePng = useCallback(async () => {
const qr = getQrInstance();
const blob = await qr.getRawData('png');
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `qr-${projectName || 'export'}.png`.replace(/[^a-z0-9.-]/gi, '-');
a.click();
URL.revokeObjectURL(url);
}, [getQrInstance, projectName]);
const handlePdf = useCallback(async () => {
const qr = getQrInstance();
const blob = await qr.getRawData('png');
if (!blob) return;
const arrayBuffer = await blob.arrayBuffer();
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([400, 500]);
const pngImage = await pdfDoc.embedPng(new Uint8Array(arrayBuffer));
const scale = Math.min(280 / pngImage.width, 280 / pngImage.height);
const w = pngImage.width * scale;
const h = pngImage.height * scale;
page.drawImage(pngImage, {
x: (400 - w) / 2,
y: (500 - h) / 2,
width: w,
height: h,
});
if (projectName) {
page.drawText(projectName, { x: 50, y: 80, size: 12 });
}
if (data) {
const urlText = data.length > 50 ? data.slice(0, 47) + '...' : data;
page.drawText(urlText, { x: 50, y: 60, size: 10 });
}
const pdfBytes = await pdfDoc.save();
const url = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
const a = document.createElement('a');
a.href = url;
a.download = `qr-${projectName || 'export'}.pdf`.replace(/[^a-z0-9.-]/gi, '-');
a.click();
URL.revokeObjectURL(url);
}, [getQrInstance, data, projectName]);
return (
<Stack gap="md" mt="md" className={classes.panel}>
<Text size="sm" fw={500}>
Export
</Text>
<Group>
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handleSvg}>
SVG
</Button>
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handlePng}>
PNG
</Button>
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handlePdf}>
PDF
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,3 @@
.content {
padding: 16px;
}

View File

@@ -0,0 +1,16 @@
'use client';
import { Stack, Text, Center } from '@mantine/core';
import classes from './ProjectsList.module.css';
export function ProjectsList() {
return (
<div className={classes.content}>
<Stack align="center" gap="md" mt="xl">
<Text c="dimmed" size="sm">
Select a project from the sidebar or create a new one.
</Text>
</Stack>
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { useEffect, useRef } from 'react';
import QRCodeStyling from 'qr-code-styling';
import type { RecipeOptions } from '@/types/project';
import { buildQrStylingOptions } from '@/lib/qrStylingOptions';
interface QrPreviewProps {
data: string;
recipe: RecipeOptions;
logoUrl?: string | null;
size?: number;
}
export function QrPreview({ data, recipe, logoUrl, size = 256 }: QrPreviewProps) {
const ref = useRef<HTMLDivElement>(null);
const qrRef = useRef<QRCodeStyling | null>(null);
useEffect(() => {
if (!ref.current) return;
const qr = new QRCodeStyling(
buildQrStylingOptions(recipe, {
width: size,
height: size,
data: data || ' ',
image: logoUrl || undefined,
}) as ConstructorParameters<typeof QRCodeStyling>[0],
);
qrRef.current = qr;
qr.append(ref.current);
return () => {
ref.current?.replaceChildren();
qrRef.current = null;
};
}, [size]);
useEffect(() => {
const qr = qrRef.current;
if (!qr) return;
qr.update(
buildQrStylingOptions(recipe, {
width: recipe.width ?? size,
height: recipe.height ?? size,
data: data || ' ',
image: logoUrl || undefined,
}) as Parameters<QRCodeStyling['update']>[0],
);
}, [data, recipe, logoUrl, size]);
return <div ref={ref} style={{ display: 'inline-block' }} />;
}

View File

@@ -0,0 +1,64 @@
.sidebar {
height: 100%;
background: var(--sidebar-bg);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.title {
display: flex;
align-items: center;
color: #e6edf3;
}
.nav {
flex: 1;
overflow: auto;
}
.navLink {
border-radius: 6px;
cursor: grab;
}
.navLink:active {
cursor: grabbing;
}
.navLink[data-active] {
background: #21262d;
}
.sectionLabel {
user-select: none;
}
.dropZone {
border-radius: 6px;
padding: 4px 0;
min-height: 24px;
transition: background 0.15s ease;
}
.dropZone[data-dragging] {
background: var(--mantine-color-blue-filled);
opacity: 0.2;
}
.folderHeader {
cursor: pointer;
border-radius: 6px;
padding: 4px 6px;
align-items: center;
user-select: none;
}
.folderHeader:hover {
background: var(--mantine-color-default-hover);
}

View File

@@ -0,0 +1,319 @@
'use client';
import { useState, useCallback } from 'react';
import {
NavLink,
Stack,
Title,
Button,
Box,
Collapse,
TextInput,
ActionIcon,
Group,
Text,
Modal,
} from '@mantine/core';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
IconPlus,
IconQrcode,
IconFolder,
IconFolderOpen,
IconChevronDown,
IconChevronRight,
IconFolderPlus,
IconTrash,
} from '@tabler/icons-react';
import { useProjects } from '@/contexts/ProjectsContext';
import classes from './Sidebar.module.css';
export interface ProjectItem {
id: string;
name: string;
updatedAt: string;
logoUrl: string | null;
folderId?: string | null;
}
export interface FolderItem {
id: string;
name: string;
sortOrder: number;
}
const UNCATEGORIZED_ID = '__uncategorized__';
export function Sidebar() {
const pathname = usePathname();
const {
projects,
folders,
moveProjectToFolder,
createFolder,
updateFolder,
deleteFolder,
} = useProjects();
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set());
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [editingFolderId, setEditingFolderId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [folderToDelete, setFolderToDelete] = useState<{
folder: FolderItem;
projectCount: number;
} | null>(null);
const toggleFolder = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleDragStart = useCallback((e: React.DragEvent, projectId: string) => {
e.dataTransfer.setData('application/x-project-id', projectId);
e.dataTransfer.effectAllowed = 'move';
}, []);
const handleDragOver = useCallback((e: React.DragEvent, zoneId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverId(zoneId);
}, []);
const handleDragLeave = useCallback(() => {
setDragOverId(null);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent, targetFolderId: string | null) => {
e.preventDefault();
setDragOverId(null);
const projectId = e.dataTransfer.getData('application/x-project-id');
if (!projectId) return;
const fid = targetFolderId === UNCATEGORIZED_ID ? null : targetFolderId;
moveProjectToFolder(projectId, fid);
},
[moveProjectToFolder],
);
const uncategorized = projects.filter((p) => !p.folderId || p.folderId === '');
const projectsByFolder = folders.map((f) => ({
folder: f,
projects: projects.filter((p) => p.folderId === f.id),
}));
const startEditFolder = (folder: FolderItem) => {
setEditingFolderId(folder.id);
setEditingName(folder.name);
};
const saveEditFolder = async () => {
if (editingFolderId && editingName.trim()) {
await updateFolder(editingFolderId, { name: editingName.trim() });
}
setEditingFolderId(null);
setEditingName('');
};
const handleDeleteFolder = useCallback(
(folder: FolderItem, projectCount: number) => {
if (projectCount === 0) {
deleteFolder(folder.id);
} else {
setFolderToDelete({ folder, projectCount });
}
},
[deleteFolder],
);
const confirmDeleteFolder = useCallback(() => {
if (folderToDelete) {
deleteFolder(folderToDelete.folder.id);
setFolderToDelete(null);
}
}, [folderToDelete, deleteFolder]);
const renderProjectLink = (p: ProjectItem) => (
<NavLink
key={p.id}
component={Link}
href={`/projects/${p.id}`}
label={p.name || 'Untitled QR'}
active={pathname === `/projects/${p.id}`}
className={classes.navLink}
draggable
onDragStart={(e) => handleDragStart(e, p.id)}
/>
);
const renderDropZone = (
zoneId: string,
label: string,
folderId: string | null,
children: React.ReactNode,
) => (
<Box
className={classes.dropZone}
data-dragging={dragOverId === zoneId ? true : undefined}
onDragOver={(e) => handleDragOver(e, zoneId)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, folderId)}
>
{children}
</Box>
);
return (
<Stack gap="md" p="md" className={classes.sidebar}>
<div className={classes.header}>
<Title order={4} className={classes.title}>
<IconQrcode size={20} style={{ marginRight: 8 }} />
QR Designer
</Title>
<Group gap={4}>
<Button
component={Link}
href="/projects/new"
leftSection={<IconPlus size={16} />}
size="xs"
variant="light"
>
New
</Button>
<Button
size="xs"
variant="subtle"
leftSection={<IconFolderPlus size={16} />}
onClick={() => {
createFolder().then((folder) => {
if (folder) setExpandedIds((prev) => new Set([...prev, folder.id]));
});
}}
>
Folder
</Button>
</Group>
</div>
<nav className={classes.nav}>
{renderDropZone(
UNCATEGORIZED_ID,
'Uncategorized',
null,
<>
<Text size="xs" c="dimmed" fw={500} mb={4} className={classes.sectionLabel}>
Uncategorized
</Text>
<Stack gap={2}>
{uncategorized.map((p) => renderProjectLink(p))}
</Stack>
</>,
)}
{projectsByFolder.map(({ folder, projects: folderProjects }) => {
const isExpanded = expandedIds.has(folder.id);
return (
<Box key={folder.id}>
{renderDropZone(
folder.id,
folder.name,
folder.id,
<>
<Group
gap={4}
className={classes.folderHeader}
onClick={() => toggleFolder(folder.id)}
>
{isExpanded ? (
<IconChevronDown size={14} />
) : (
<IconChevronRight size={14} />
)}
{isExpanded ? (
<IconFolderOpen size={16} />
) : (
<IconFolder size={16} />
)}
{editingFolderId === folder.id ? (
<TextInput
size="xs"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onBlur={saveEditFolder}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEditFolder();
}}
onClick={(e) => e.stopPropagation()}
autoFocus
/>
) : (
<Text
size="sm"
fw={500}
style={{ flex: 1 }}
onDoubleClick={(e) => {
e.stopPropagation();
startEditFolder(folder);
}}
>
{folder.name}
</Text>
)}
{editingFolderId !== folder.id && (
<ActionIcon
size="xs"
variant="subtle"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(folder, folderProjects.length);
}}
aria-label="Delete folder"
>
<IconTrash size={12} />
</ActionIcon>
)}
</Group>
<Collapse in={isExpanded}>
<Stack gap={2} pl="md" mt={4}>
{folderProjects.map((p) => renderProjectLink(p))}
</Stack>
</Collapse>
</>,
)}
</Box>
);
})}
</nav>
<Modal
opened={folderToDelete !== null}
onClose={() => setFolderToDelete(null)}
title="Delete folder?"
centered
>
{folderToDelete && (
<>
<Text size="sm" c="dimmed" mb="md">
This folder contains {folderToDelete.projectCount} project
{folderToDelete.projectCount === 1 ? '' : 's'}. They will be moved to
Uncategorized. Delete folder &quot;{folderToDelete.folder.name}&quot;?
</Text>
<Group justify="flex-end" gap="xs">
<Button
variant="subtle"
onClick={() => setFolderToDelete(null)}
>
Cancel
</Button>
<Button color="red" onClick={confirmDeleteFolder}>
Delete folder
</Button>
</Group>
</>
)}
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import { createContext, useCallback, useContext, useState } from 'react';
import type { ProjectItem, FolderItem } from '@/components/Sidebar';
interface ProjectsContextValue {
projects: ProjectItem[];
folders: FolderItem[];
setProjects: React.Dispatch<React.SetStateAction<ProjectItem[]>>;
setFolders: React.Dispatch<React.SetStateAction<FolderItem[]>>;
refetch: () => void;
updateProjectInList: (id: string, patch: Partial<ProjectItem>) => void;
removeProjectFromList: (id: string) => void;
moveProjectToFolder: (projectId: string, folderId: string | null) => Promise<void>;
createFolder: (name?: string) => Promise<FolderItem | null>;
updateFolder: (id: string, patch: Partial<FolderItem>) => Promise<void>;
deleteFolder: (id: string) => Promise<void>;
}
const ProjectsContext = createContext<ProjectsContextValue | null>(null);
export function useProjects(): ProjectsContextValue {
const ctx = useContext(ProjectsContext);
if (!ctx) {
throw new Error('useProjects must be used within ProjectsProvider');
}
return ctx;
}
export function ProjectsProvider({
children,
}: {
children: React.ReactNode;
}) {
const [projects, setProjects] = useState<ProjectItem[]>([]);
const [folders, setFolders] = useState<FolderItem[]>([]);
const refetch = useCallback(() => {
Promise.all([
fetch('/api/projects').then((r) => r.json()),
fetch('/api/folders').then((r) => r.json()),
])
.then(([projectsData, foldersData]) => {
setProjects(Array.isArray(projectsData) ? projectsData : []);
setFolders(Array.isArray(foldersData) ? foldersData : []);
})
.catch(() => {
setProjects([]);
setFolders([]);
});
}, []);
const updateProjectInList = useCallback((id: string, patch: Partial<ProjectItem>) => {
setProjects((prev) =>
prev.map((p) => (p.id === id ? { ...p, ...patch } : p)),
);
}, []);
const removeProjectFromList = useCallback((id: string) => {
setProjects((prev) => prev.filter((p) => p.id !== id));
}, []);
const moveProjectToFolder = useCallback(
async (projectId: string, folderId: string | null) => {
const res = await fetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folderId }),
});
if (!res.ok) return;
updateProjectInList(projectId, { folderId });
},
[updateProjectInList],
);
const createFolder = useCallback(async (name = 'New folder') => {
const res = await fetch('/api/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) return null;
const folder = await res.json();
setFolders((prev) => [...prev, folder].sort((a, b) => a.sortOrder - b.sortOrder));
return folder;
}, []);
const updateFolder = useCallback(async (id: string, patch: Partial<FolderItem>) => {
const res = await fetch(`/api/folders/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) return;
const folder = await res.json();
setFolders((prev) =>
prev.map((f) => (f.id === id ? folder : f)).sort((a, b) => a.sortOrder - b.sortOrder),
);
}, []);
const deleteFolder = useCallback(async (id: string) => {
const res = await fetch(`/api/folders/${id}`, { method: 'DELETE' });
if (!res.ok) return;
setFolders((prev) => prev.filter((f) => f.id !== id));
setProjects((prev) =>
prev.map((p) => (p.folderId === id ? { ...p, folderId: null } : p)),
);
}, []);
return (
<ProjectsContext.Provider
value={{
projects,
folders,
setProjects,
setFolders,
refetch,
updateProjectInList,
removeProjectFromList,
moveProjectToFolder,
createFolder,
updateFolder,
deleteFolder,
}}
>
{children}
</ProjectsContext.Provider>
);
}

View File

@@ -0,0 +1,95 @@
import type { RecipeOptions, QrGradient } from '@/types/project';
type DotType =
| 'square'
| 'rounded'
| 'dots'
| 'classy'
| 'classy-rounded'
| 'extra-rounded';
type CornerType = 'square' | 'dot' | 'extra-rounded' | DotType;
type ErrorLevel = 'L' | 'M' | 'Q' | 'H';
export interface QrStylingOverrides {
width?: number;
height?: number;
data?: string;
image?: string;
}
/** Build options for qr-code-styling from RecipeOptions (shared by QrPreview and ExportPanel). */
export function buildQrStylingOptions(
recipe: RecipeOptions,
overrides: QrStylingOverrides = {},
): Record<string, unknown> {
const opts: Record<string, unknown> = {
width: overrides.width ?? recipe.width ?? 256,
height: overrides.height ?? recipe.height ?? 256,
data: overrides.data ?? recipe.data ?? ' ',
image: overrides.image,
type: 'canvas',
shape: recipe.shape ?? 'square',
margin: recipe.margin ?? 0,
qrOptions: {
type: 'canvas',
mode: 'Byte',
errorCorrectionLevel:
(recipe.qrOptions?.errorCorrectionLevel as ErrorLevel) ?? 'M',
},
imageOptions: {
hideBackgroundDots: recipe.imageOptions?.hideBackgroundDots ?? true,
imageSize: recipe.imageOptions?.imageSize ?? 0.4,
margin: recipe.imageOptions?.margin ?? 0,
},
};
const bg = recipe.backgroundOptions;
opts.backgroundOptions = {
color: bg?.color ?? '#ffffff',
round: bg?.round ?? 0,
...(bg?.gradient && { gradient: bg.gradient }),
};
const dots = recipe.dotsOptions;
opts.dotsOptions = {
type: (dots?.type as DotType) ?? 'square',
color: dots?.color ?? '#000000',
roundSize: dots?.roundSize ?? false,
...(dots?.gradient && { gradient: dots.gradient }),
};
const cornersSq = recipe.cornersSquareOptions;
opts.cornersSquareOptions = {
type: (cornersSq?.type as CornerType) ?? 'square',
color: cornersSq?.color ?? '#000000',
...(cornersSq?.gradient && { gradient: cornersSq.gradient }),
};
const cornersDot = recipe.cornersDotOptions;
opts.cornersDotOptions = {
type: (cornersDot?.type as CornerType) ?? (cornersSq?.type as CornerType) ?? 'square',
color: cornersDot?.color ?? cornersSq?.color ?? '#000000',
...((cornersDot?.gradient ?? cornersSq?.gradient) && {
gradient: cornersDot?.gradient ?? cornersSq?.gradient,
}),
};
return opts;
}
/** Create a simple two-stop gradient (for UI defaults). */
export function makeGradient(
type: 'linear' | 'radial',
color1: string,
color2: string,
rotation = 0,
): QrGradient {
return {
type,
rotation,
colorStops: [
{ offset: 0, color: color1 },
{ offset: 1, color: color2 },
],
};
}

View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import type { RecipeOptions } from '@/types/project';
describe('recipe serialization', () => {
it('round-trips recipe JSON', () => {
const recipe: RecipeOptions = {
width: 300,
height: 300,
data: 'https://mifi.me/abc',
qrOptions: { errorCorrectionLevel: 'M' },
backgroundOptions: { color: '#ffffff' },
dotsOptions: { color: '#000000', type: 'rounded' },
cornersSquareOptions: { color: '#000000', type: 'dot' },
};
const json = JSON.stringify(recipe);
const parsed = JSON.parse(json) as RecipeOptions;
expect(parsed.data).toBe('https://mifi.me/abc');
expect(parsed.dotsOptions?.type).toBe('rounded');
expect(parsed.cornersSquareOptions?.type).toBe('dot');
});
});

View File

@@ -0,0 +1,55 @@
export interface Project {
id: string;
name: string;
createdAt: string;
updatedAt: string;
originalUrl: string;
shortenEnabled: boolean;
shortUrl: string | null;
recipeJson: string;
logoFilename: string | null;
logoUrl?: string | null;
}
export type ContentType = 'url' | 'text' | 'email' | 'phone';
/** Matches qr-code-styling Gradient: linear/radial with rotation and color stops */
export interface QrGradient {
type: 'linear' | 'radial';
rotation?: number;
colorStops: Array<{ offset: number; color: string }>;
}
export interface RecipeOptions {
width?: number;
height?: number;
data?: string;
contentType?: ContentType;
image?: string;
qrOptions?: { type?: string; mode?: string; errorCorrectionLevel?: string };
imageOptions?: { hideBackgroundDots?: boolean; imageSize?: number; margin?: number };
backgroundOptions?: {
color?: string;
gradient?: QrGradient;
round?: number;
};
dotsOptions?: {
color?: string;
type?: string;
gradient?: QrGradient;
roundSize?: boolean;
};
cornersSquareOptions?: { color?: string; type?: string; gradient?: QrGradient };
cornersDotOptions?: { color?: string; type?: string; gradient?: QrGradient };
shape?: 'square' | 'circle';
margin?: number;
}
export const DEFAULT_RECIPE: RecipeOptions = {
width: 300,
height: 300,
qrOptions: { type: 'canvas', mode: 'Byte', errorCorrectionLevel: 'M' },
backgroundOptions: { color: '#ffffff' },
dotsOptions: { color: '#000000', type: 'square' },
cornersSquareOptions: { color: '#000000', type: 'square' },
};

21
qr-web/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

15
qr-web/vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: false,
include: ['src/**/*.test.{ts,tsx}'],
},
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
});