Initial commit
This commit is contained in:
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