Initial commit
This commit is contained in:
33
.devcontainer/devcontainer.json
Normal file
33
.devcontainer/devcontainer.json
Normal 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
21
.env.example
Normal 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
36
.eslintrc.cjs
Normal 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
24
.gitignore
vendored
Normal 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
12
.prettierignore
Normal 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
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"semi": true
|
||||||
|
}
|
||||||
68
.woodpecker/ci.yml
Normal file
68
.woodpecker/ci.yml
Normal 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
93
.woodpecker/deploy.yml
Normal 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
104
README.md
Normal 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 don’t 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 Kutt’s 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
112
docker-compose.yml
Normal 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
23
package.json
Normal 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
7786
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "qr-api"
|
||||||
|
- "qr-web"
|
||||||
27
qr-api/.dockerignore
Normal file
27
qr-api/.dockerignore
Normal 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
17
qr-api/Dockerfile
Normal 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
40
qr-api/package.json
Normal 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
65
qr-api/src/db.test.ts
Normal 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
160
qr-api/src/db.ts
Normal 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
20
qr-api/src/env.ts
Normal 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
55
qr-api/src/index.ts
Normal 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');
|
||||||
|
});
|
||||||
86
qr-api/src/routes/folders.ts
Normal file
86
qr-api/src/routes/folders.ts
Normal 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;
|
||||||
|
}
|
||||||
125
qr-api/src/routes/projects.ts
Normal file
125
qr-api/src/routes/projects.ts
Normal 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;
|
||||||
|
}
|
||||||
32
qr-api/src/routes/shorten.ts
Normal file
32
qr-api/src/routes/shorten.ts
Normal 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;
|
||||||
|
}
|
||||||
35
qr-api/src/routes/uploads.ts
Normal file
35
qr-api/src/routes/uploads.ts
Normal 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;
|
||||||
|
}
|
||||||
72
qr-api/src/shorten.test.ts
Normal file
72
qr-api/src/shorten.test.ts
Normal 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
43
qr-api/src/shorten.ts
Normal 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
27
qr-api/src/upload.ts
Normal 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
17
qr-api/tsconfig.json
Normal 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
8
qr-api/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: false,
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
30
qr-web/.dockerignore
Normal file
30
qr-web/.dockerignore
Normal 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
9
qr-web/.eslintrc.cjs
Normal 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
22
qr-web/Dockerfile
Normal 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
6
qr-web/next-env.d.ts
vendored
Normal 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
8
qr-web/next.config.ts
Normal 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
45
qr-web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
qr-web/postcss.config.mjs
Normal file
9
qr-web/postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-nesting': {},
|
||||||
|
'postcss-preset-env': { stage: 2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
57
qr-web/src/app/api/folders/[id]/route.ts
Normal file
57
qr-web/src/app/api/folders/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
qr-web/src/app/api/folders/route.ts
Normal file
32
qr-web/src/app/api/folders/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
63
qr-web/src/app/api/projects/[id]/route.ts
Normal file
63
qr-web/src/app/api/projects/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
39
qr-web/src/app/api/projects/route.ts
Normal file
39
qr-web/src/app/api/projects/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
19
qr-web/src/app/api/shorten/route.ts
Normal file
19
qr-web/src/app/api/shorten/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
12
qr-web/src/app/globals.css
Normal file
12
qr-web/src/app/globals.css
Normal 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
22
qr-web/src/app/layout.tsx
Normal 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
5
qr-web/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/projects');
|
||||||
|
}
|
||||||
10
qr-web/src/app/projects/[id]/page.tsx
Normal file
10
qr-web/src/app/projects/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
3
qr-web/src/app/projects/layout.module.css
Normal file
3
qr-web/src/app/projects/layout.module.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.main {
|
||||||
|
background: var(--app-bg);
|
||||||
|
}
|
||||||
44
qr-web/src/app/projects/layout.tsx
Normal file
44
qr-web/src/app/projects/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
qr-web/src/app/projects/new/page.tsx
Normal file
35
qr-web/src/app/projects/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
qr-web/src/app/projects/page.tsx
Normal file
5
qr-web/src/app/projects/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProjectsList } from '@/components/ProjectsList';
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
return <ProjectsList />;
|
||||||
|
}
|
||||||
28
qr-web/src/components/Editor.module.css
Normal file
28
qr-web/src/components/Editor.module.css
Normal 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;
|
||||||
|
}
|
||||||
840
qr-web/src/components/Editor.tsx
Normal file
840
qr-web/src/components/Editor.tsx
Normal 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 (7–15 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.1–0.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 "{project.name || 'Untitled QR'}" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
6
qr-web/src/components/ExportPanel.module.css
Normal file
6
qr-web/src/components/ExportPanel.module.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.panel {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
113
qr-web/src/components/ExportPanel.tsx
Normal file
113
qr-web/src/components/ExportPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
qr-web/src/components/ProjectsList.module.css
Normal file
3
qr-web/src/components/ProjectsList.module.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
16
qr-web/src/components/ProjectsList.tsx
Normal file
16
qr-web/src/components/ProjectsList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
qr-web/src/components/QrPreview.tsx
Normal file
51
qr-web/src/components/QrPreview.tsx
Normal 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' }} />;
|
||||||
|
}
|
||||||
64
qr-web/src/components/Sidebar.module.css
Normal file
64
qr-web/src/components/Sidebar.module.css
Normal 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);
|
||||||
|
}
|
||||||
319
qr-web/src/components/Sidebar.tsx
Normal file
319
qr-web/src/components/Sidebar.tsx
Normal 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 "{folderToDelete.folder.name}"?
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
qr-web/src/contexts/ProjectsContext.tsx
Normal file
129
qr-web/src/contexts/ProjectsContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
qr-web/src/lib/qrStylingOptions.ts
Normal file
95
qr-web/src/lib/qrStylingOptions.ts
Normal 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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
21
qr-web/src/lib/recipe.test.ts
Normal file
21
qr-web/src/lib/recipe.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
55
qr-web/src/types/project.ts
Normal file
55
qr-web/src/types/project.ts
Normal 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
21
qr-web/tsconfig.json
Normal 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
15
qr-web/vitest.config.ts
Normal 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') },
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user