Resolve linter issues, add unit tests, adjust test coverage
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['next/core-web-vitals', 'prettier'],
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
};
|
||||
18
qr-web/eslint.config.cjs
Normal file
18
qr-web/eslint.config.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
const nextConfig = require('eslint-config-next/core-web-vitals');
|
||||
const prettierConfig = require('eslint-config-prettier');
|
||||
|
||||
module.exports = [
|
||||
...nextConfig,
|
||||
prettierConfig,
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
2
qr-web/next-env.d.ts
vendored
2
qr-web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -15,11 +19,11 @@
|
||||
"@tabler/icons-react": "^3.23.0",
|
||||
"@mantine/dropzone": "^7.14.0",
|
||||
"@mantine/hooks": "^7.14.0",
|
||||
"next": "^15.0.3",
|
||||
"next": "^16.1.6",
|
||||
"pdf-lib": "^1.4.2",
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.0.1",
|
||||
@@ -29,7 +33,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-next": "^15.0.3",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"postcss": "^8.4.49",
|
||||
@@ -37,6 +41,7 @@
|
||||
"postcss-preset-env": "^10.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^2.1.6",
|
||||
"@vitest/coverage-v8": "^2.1.6",
|
||||
"@vitejs/plugin-react": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -6,10 +6,15 @@ export async function GET(
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const res = await fetch(`${QR_API_URL}/folders/${id}`, { cache: 'no-store' });
|
||||
const res = await fetch(`${QR_API_URL}/folders/${id}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return Response.json({ error: data?.error ?? 'Not found' }, { status: res.status });
|
||||
return Response.json(
|
||||
{ error: data?.error ?? 'Not found' },
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
return Response.json(data);
|
||||
} catch (e) {
|
||||
@@ -31,7 +36,10 @@ export async function PUT(
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
|
||||
return Response.json(
|
||||
{ error: data?.error ?? 'Failed' },
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
return Response.json(data);
|
||||
} catch (e) {
|
||||
@@ -45,12 +53,17 @@ export async function DELETE(
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const res = await fetch(`${QR_API_URL}/folders/${id}`, { method: 'DELETE' });
|
||||
const res = await fetch(`${QR_API_URL}/folders/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.status === 204) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
const data = await res.json();
|
||||
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
|
||||
return Response.json(
|
||||
{ error: data?.error ?? 'Failed' },
|
||||
{ status: res.status },
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.json({ error: String(e) }, { status: 502 });
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ export async function GET() {
|
||||
const res = await fetch(`${QR_API_URL}/folders`, { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
|
||||
return Response.json(
|
||||
{ error: data?.error ?? 'Failed' },
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
return Response.json(Array.isArray(data) ? data : data);
|
||||
} catch (e) {
|
||||
@@ -23,7 +26,10 @@ export async function POST(request: Request) {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
|
||||
return Response.json(
|
||||
{ error: data?.error ?? 'Failed' },
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
return Response.json(data);
|
||||
} catch (e) {
|
||||
|
||||
@@ -6,13 +6,21 @@ export async function GET(
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const res = await fetch(`${QR_API_URL}/projects/${id}`, { cache: 'no-store' });
|
||||
const res = await fetch(`${QR_API_URL}/projects/${id}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return Response.json({ error: data?.error ?? 'Not found' }, { status: res.status });
|
||||
return Response.json(
|
||||
{ error: data?.error ?? 'Not found' },
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
if (data?.logoUrl) {
|
||||
data.logoUrl = data.logoUrl.replace(/^\/uploads\//, '/api/uploads/');
|
||||
data.logoUrl = data.logoUrl.replace(
|
||||
/^\/uploads\//,
|
||||
'/api/uploads/',
|
||||
);
|
||||
}
|
||||
return Response.json(data);
|
||||
} catch (e) {
|
||||
@@ -34,10 +42,16 @@ export async function PUT(
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
|
||||
return Response.json(
|
||||
{ error: data?.error ?? 'Failed' },
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
if (data?.logoUrl) {
|
||||
data.logoUrl = data.logoUrl.replace(/^\/uploads\//, '/api/uploads/');
|
||||
data.logoUrl = data.logoUrl.replace(
|
||||
/^\/uploads\//,
|
||||
'/api/uploads/',
|
||||
);
|
||||
}
|
||||
return Response.json(data);
|
||||
} catch (e) {
|
||||
@@ -51,12 +65,17 @@ export async function DELETE(
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const res = await fetch(`${QR_API_URL}/projects/${id}`, { method: 'DELETE' });
|
||||
const res = await fetch(`${QR_API_URL}/projects/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.status === 204) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
const data = await res.json();
|
||||
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
|
||||
return Response.json(
|
||||
{ error: data?.error ?? 'Failed' },
|
||||
{ status: res.status },
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.json({ error: String(e) }, { status: 502 });
|
||||
}
|
||||
|
||||
@@ -9,10 +9,15 @@ function rewriteLogoUrl(items: Array<{ logoUrl?: string | null }>) {
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(`${QR_API_URL}/projects`, { cache: 'no-store' });
|
||||
const res = await fetch(`${QR_API_URL}/projects`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
|
||||
return Response.json(
|
||||
{ error: data?.error ?? 'Failed' },
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
return Response.json(Array.isArray(data) ? rewriteLogoUrl(data) : data);
|
||||
} catch (e) {
|
||||
@@ -30,7 +35,10 @@ export async function POST(request: Request) {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return Response.json({ error: data?.error ?? 'Failed' }, { status: res.status });
|
||||
return Response.json(
|
||||
{ error: data?.error ?? 'Failed' },
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
return Response.json(data);
|
||||
} catch (e) {
|
||||
|
||||
@@ -10,7 +10,10 @@ export async function POST(request: Request) {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
return Response.json({ error: data?.error ?? 'Shorten failed' }, { status: res.status });
|
||||
return Response.json(
|
||||
{ error: data?.error ?? 'Shorten failed' },
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
return Response.json(data);
|
||||
} catch (e) {
|
||||
|
||||
@@ -8,5 +8,8 @@ body {
|
||||
margin: 0;
|
||||
background: var(--app-bg);
|
||||
color: #e6edf3;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,7 @@ import { Sidebar } from '@/components/Sidebar';
|
||||
import { ProjectsProvider, useProjects } from '@/contexts/ProjectsContext';
|
||||
import classes from './layout.module.css';
|
||||
|
||||
function ProjectsLayoutInner({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
function ProjectsLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
const { refetch } = useProjects();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,14 +20,25 @@ import {
|
||||
Button,
|
||||
ActionIcon,
|
||||
} from '@mantine/core';
|
||||
import { IconLink, IconFileText, IconMail, IconPhone, IconTrash } from '@tabler/icons-react';
|
||||
import {
|
||||
IconLink,
|
||||
IconFileText,
|
||||
IconMail,
|
||||
IconPhone,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
|
||||
import { useDebouncedCallback } from '@mantine/hooks';
|
||||
import { QrPreview } from './QrPreview';
|
||||
import { ExportPanel } from './ExportPanel';
|
||||
import { useProjects } from '@/contexts/ProjectsContext';
|
||||
import type { Project, RecipeOptions, ContentType, QrGradient } from '@/types/project';
|
||||
import type {
|
||||
Project,
|
||||
RecipeOptions,
|
||||
ContentType,
|
||||
QrGradient,
|
||||
} from '@/types/project';
|
||||
import { makeGradient } from '@/lib/qrStylingOptions';
|
||||
import classes from './Editor.module.css';
|
||||
|
||||
@@ -49,7 +60,11 @@ const CONTENT_TYPES: Array<{
|
||||
placeholder: 'https://example.com',
|
||||
inputLabel: 'Website address',
|
||||
validate: (v) =>
|
||||
!v.trim() ? 'Enter a URL' : /^https?:\/\/.+/i.test(v.trim()) ? null : 'URL must start with http:// or https://',
|
||||
!v.trim()
|
||||
? 'Enter a URL'
|
||||
: /^https?:\/\/.+/i.test(v.trim())
|
||||
? null
|
||||
: 'URL must start with http:// or https://',
|
||||
},
|
||||
{
|
||||
value: 'text',
|
||||
@@ -76,7 +91,9 @@ const CONTENT_TYPES: Array<{
|
||||
validate: (v) => {
|
||||
if (!v.trim()) return 'Enter an email address';
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(v.trim()) ? null : 'Enter a valid email address';
|
||||
return emailRegex.test(v.trim())
|
||||
? null
|
||||
: 'Enter a valid email address';
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -92,13 +109,16 @@ const CONTENT_TYPES: Array<{
|
||||
validate: (v) => {
|
||||
if (!v.trim()) return 'Enter a phone number';
|
||||
const digits = v.replace(/\D/g, '');
|
||||
return digits.length >= 7 && digits.length <= 15 ? null : 'Enter a valid phone number (7–15 digits)';
|
||||
return digits.length >= 7 && digits.length <= 15
|
||||
? null
|
||||
: 'Enter a valid phone number (7–15 digits)';
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function inferContentType(content: string, current?: ContentType): ContentType {
|
||||
if (current && CONTENT_TYPES.some((t) => t.value === current)) return current;
|
||||
if (current && CONTENT_TYPES.some((t) => t.value === current))
|
||||
return current;
|
||||
const t = content.trim();
|
||||
if (/^https?:\/\//i.test(t)) return 'url';
|
||||
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) return 'email';
|
||||
@@ -229,11 +249,16 @@ export function Editor({ id }: EditorProps) {
|
||||
shortenEnabled: true,
|
||||
recipeJson: (() => {
|
||||
try {
|
||||
const recipe = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
|
||||
const recipe = JSON.parse(
|
||||
project.recipeJson || '{}',
|
||||
) as RecipeOptions;
|
||||
recipe.data = data.shortUrl;
|
||||
return JSON.stringify(recipe);
|
||||
} catch {
|
||||
return JSON.stringify({ ...project, data: data.shortUrl });
|
||||
return JSON.stringify({
|
||||
...project,
|
||||
data: data.shortUrl,
|
||||
});
|
||||
}
|
||||
})(),
|
||||
});
|
||||
@@ -271,10 +296,17 @@ export function Editor({ id }: EditorProps) {
|
||||
if (!project) return;
|
||||
setContentTouched(false);
|
||||
try {
|
||||
const r = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
|
||||
const r = JSON.parse(
|
||||
project.recipeJson || '{}',
|
||||
) as RecipeOptions;
|
||||
r.contentType = type;
|
||||
const patch: Partial<Project> = { recipeJson: JSON.stringify(r) };
|
||||
if (type !== 'url' && (project.shortenEnabled || project.shortUrl)) {
|
||||
const patch: Partial<Project> = {
|
||||
recipeJson: JSON.stringify(r),
|
||||
};
|
||||
if (
|
||||
type !== 'url' &&
|
||||
(project.shortenEnabled || project.shortUrl)
|
||||
) {
|
||||
patch.shortenEnabled = false;
|
||||
patch.shortUrl = null;
|
||||
r.data = (project.originalUrl ?? '') || undefined;
|
||||
@@ -293,15 +325,23 @@ export function Editor({ id }: EditorProps) {
|
||||
const content = project.originalUrl ?? '';
|
||||
let recipe: RecipeOptions = {};
|
||||
try {
|
||||
recipe = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
|
||||
recipe = JSON.parse(
|
||||
project.recipeJson || '{}',
|
||||
) as RecipeOptions;
|
||||
} catch {
|
||||
recipe = {};
|
||||
}
|
||||
const contentType = inferContentType(content, recipe.contentType);
|
||||
try {
|
||||
const r = JSON.parse(project.recipeJson || '{}') as RecipeOptions;
|
||||
const r = JSON.parse(
|
||||
project.recipeJson || '{}',
|
||||
) as RecipeOptions;
|
||||
r.contentType = contentType;
|
||||
if (contentType === 'url' && project.shortenEnabled && project.shortUrl) {
|
||||
if (
|
||||
contentType === 'url' &&
|
||||
project.shortenEnabled &&
|
||||
project.shortUrl
|
||||
) {
|
||||
r.data = project.shortUrl;
|
||||
} else {
|
||||
r.data = value || undefined;
|
||||
@@ -310,7 +350,10 @@ export function Editor({ id }: EditorProps) {
|
||||
originalUrl: value,
|
||||
recipeJson: JSON.stringify(r),
|
||||
};
|
||||
if (contentType !== 'url' && (project.shortenEnabled || project.shortUrl)) {
|
||||
if (
|
||||
contentType !== 'url' &&
|
||||
(project.shortenEnabled || project.shortUrl)
|
||||
) {
|
||||
patch.shortenEnabled = false;
|
||||
patch.shortUrl = null;
|
||||
r.data = value || undefined;
|
||||
@@ -352,7 +395,8 @@ export function Editor({ id }: EditorProps) {
|
||||
}
|
||||
const content = project.originalUrl ?? '';
|
||||
const contentType = inferContentType(content, recipe.contentType);
|
||||
const typeConfig = CONTENT_TYPES.find((t) => t.value === contentType) ?? CONTENT_TYPES[0];
|
||||
const typeConfig =
|
||||
CONTENT_TYPES.find((t) => t.value === contentType) ?? CONTENT_TYPES[0];
|
||||
const contentError = contentTouched ? typeConfig.validate(content) : null;
|
||||
const isUrl = contentType === 'url';
|
||||
const qrData =
|
||||
@@ -366,7 +410,11 @@ export function Editor({ id }: EditorProps) {
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{saving ? 'Saving…' : lastSaved ? `Saved ${lastSaved.toLocaleTimeString()}` : ''}
|
||||
{saving
|
||||
? 'Saving…'
|
||||
: lastSaved
|
||||
? `Saved ${lastSaved.toLocaleTimeString()}`
|
||||
: ''}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
@@ -382,7 +430,9 @@ export function Editor({ id }: EditorProps) {
|
||||
label="Project name"
|
||||
placeholder="Untitled QR"
|
||||
value={project.name}
|
||||
onChange={(e) => updateProject({ name: e.target.value })}
|
||||
onChange={(e) =>
|
||||
updateProject({ name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Text size="sm" fw={500}>
|
||||
Content type
|
||||
@@ -390,7 +440,10 @@ export function Editor({ id }: EditorProps) {
|
||||
<SegmentedControl
|
||||
value={contentType}
|
||||
onChange={(v) => setContentType(v as ContentType)}
|
||||
data={CONTENT_TYPES.map((t) => ({ value: t.value, label: t.label }))}
|
||||
data={CONTENT_TYPES.map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
}))}
|
||||
fullWidth
|
||||
/>
|
||||
<TextInput
|
||||
@@ -418,8 +471,11 @@ export function Editor({ id }: EditorProps) {
|
||||
checked={project.shortenEnabled}
|
||||
onChange={(e) => {
|
||||
const checked = e.currentTarget.checked;
|
||||
updateProject({ shortenEnabled: checked });
|
||||
if (checked && project.originalUrl) handleShorten();
|
||||
updateProject({
|
||||
shortenEnabled: checked,
|
||||
});
|
||||
if (checked && project.originalUrl)
|
||||
handleShorten();
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
@@ -451,24 +507,33 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.imageOptions?.imageSize ?? 0.4}
|
||||
onChange={(n) => {
|
||||
const r = { ...recipe };
|
||||
const v = typeof n === 'string' ? parseFloat(n) : n;
|
||||
const v =
|
||||
typeof n === 'string' ? parseFloat(n) : n;
|
||||
r.imageOptions = {
|
||||
...r.imageOptions,
|
||||
imageSize: Number.isFinite(v) ? Math.max(0.1, Math.min(0.6, v)) : 0.4,
|
||||
imageSize: Number.isFinite(v)
|
||||
? Math.max(0.1, Math.min(0.6, v))
|
||||
: 0.4,
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
label="Hide dots behind logo"
|
||||
checked={recipe.imageOptions?.hideBackgroundDots ?? true}
|
||||
checked={
|
||||
recipe.imageOptions?.hideBackgroundDots ?? true
|
||||
}
|
||||
onChange={(e) => {
|
||||
const r = { ...recipe };
|
||||
r.imageOptions = {
|
||||
...r.imageOptions,
|
||||
hideBackgroundDots: e.currentTarget.checked,
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
@@ -477,7 +542,9 @@ export function Editor({ id }: EditorProps) {
|
||||
Foreground
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={recipe.dotsOptions?.gradient ? 'gradient' : 'solid'}
|
||||
value={
|
||||
recipe.dotsOptions?.gradient ? 'gradient' : 'solid'
|
||||
}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
if (v === 'gradient') {
|
||||
@@ -487,13 +554,42 @@ export function Editor({ id }: EditorProps) {
|
||||
'#444444',
|
||||
0,
|
||||
);
|
||||
r.dotsOptions = { ...r.dotsOptions, gradient: g, color: undefined };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g, color: undefined };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g, color: undefined };
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
gradient: g,
|
||||
color: undefined,
|
||||
};
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
gradient: g,
|
||||
color: undefined,
|
||||
};
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
gradient: g,
|
||||
color: undefined,
|
||||
};
|
||||
} else {
|
||||
r.dotsOptions = { ...r.dotsOptions, gradient: undefined, color: recipe.dotsOptions?.color ?? '#000000' };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: undefined, color: recipe.cornersSquareOptions?.color ?? '#000000' };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: undefined, color: recipe.cornersSquareOptions?.color ?? '#000000' };
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
gradient: undefined,
|
||||
color:
|
||||
recipe.dotsOptions?.color ?? '#000000',
|
||||
};
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
gradient: undefined,
|
||||
color:
|
||||
recipe.cornersSquareOptions?.color ??
|
||||
'#000000',
|
||||
};
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
gradient: undefined,
|
||||
color:
|
||||
recipe.cornersSquareOptions?.color ??
|
||||
'#000000',
|
||||
};
|
||||
}
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
}}
|
||||
@@ -517,61 +613,147 @@ export function Editor({ id }: EditorProps) {
|
||||
const r = { ...recipe };
|
||||
const g: QrGradient = {
|
||||
...recipe.dotsOptions!.gradient!,
|
||||
type: (v as 'linear' | 'radial') ?? 'linear',
|
||||
type:
|
||||
(v as 'linear' | 'radial') ??
|
||||
'linear',
|
||||
};
|
||||
r.dotsOptions = { ...r.dotsOptions, gradient: g };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g };
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
gradient: g,
|
||||
};
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
gradient: g,
|
||||
};
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
gradient: g,
|
||||
};
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Rotation (°)"
|
||||
min={0}
|
||||
max={360}
|
||||
value={recipe.dotsOptions.gradient.rotation ?? 0}
|
||||
value={
|
||||
recipe.dotsOptions.gradient.rotation ??
|
||||
0
|
||||
}
|
||||
onChange={(n) => {
|
||||
const r = { ...recipe };
|
||||
const g: QrGradient = {
|
||||
...recipe.dotsOptions!.gradient!,
|
||||
rotation: typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0,
|
||||
rotation:
|
||||
typeof n === 'string'
|
||||
? parseInt(n, 10) || 0
|
||||
: (n ?? 0),
|
||||
};
|
||||
r.dotsOptions = { ...r.dotsOptions, gradient: g };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g };
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
gradient: g,
|
||||
};
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
gradient: g,
|
||||
};
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
gradient: g,
|
||||
};
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<ColorInput
|
||||
label="Start color"
|
||||
value={recipe.dotsOptions.gradient.colorStops[0]?.color ?? '#000000'}
|
||||
value={
|
||||
recipe.dotsOptions.gradient
|
||||
.colorStops[0]?.color ?? '#000000'
|
||||
}
|
||||
onChange={(c) => {
|
||||
const r = { ...recipe };
|
||||
const stops = [...(recipe.dotsOptions!.gradient!.colorStops || [])];
|
||||
if (stops[0]) stops[0] = { ...stops[0], color: c };
|
||||
else stops.unshift({ offset: 0, color: c });
|
||||
const g: QrGradient = { ...recipe.dotsOptions!.gradient!, colorStops: stops };
|
||||
r.dotsOptions = { ...r.dotsOptions, gradient: g };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g };
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
const stops = [
|
||||
...(recipe.dotsOptions!.gradient!
|
||||
.colorStops || []),
|
||||
];
|
||||
if (stops[0])
|
||||
stops[0] = {
|
||||
...stops[0],
|
||||
color: c,
|
||||
};
|
||||
else
|
||||
stops.unshift({
|
||||
offset: 0,
|
||||
color: c,
|
||||
});
|
||||
const g: QrGradient = {
|
||||
...recipe.dotsOptions!.gradient!,
|
||||
colorStops: stops,
|
||||
};
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
gradient: g,
|
||||
};
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
gradient: g,
|
||||
};
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
gradient: g,
|
||||
};
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ColorInput
|
||||
label="End color"
|
||||
value={recipe.dotsOptions.gradient.colorStops[1]?.color ?? recipe.dotsOptions.gradient.colorStops[0]?.color ?? '#444444'}
|
||||
value={
|
||||
recipe.dotsOptions.gradient
|
||||
.colorStops[1]?.color ??
|
||||
recipe.dotsOptions.gradient
|
||||
.colorStops[0]?.color ??
|
||||
'#444444'
|
||||
}
|
||||
onChange={(c) => {
|
||||
const r = { ...recipe };
|
||||
const stops = [...(recipe.dotsOptions!.gradient!.colorStops || [])];
|
||||
if (stops[1]) stops[1] = { ...stops[1], color: c };
|
||||
else stops.push({ offset: 1, color: c });
|
||||
const g: QrGradient = { ...recipe.dotsOptions!.gradient!, colorStops: stops };
|
||||
r.dotsOptions = { ...r.dotsOptions, gradient: g };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, gradient: g };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, gradient: g };
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
const stops = [
|
||||
...(recipe.dotsOptions!.gradient!
|
||||
.colorStops || []),
|
||||
];
|
||||
if (stops[1])
|
||||
stops[1] = {
|
||||
...stops[1],
|
||||
color: c,
|
||||
};
|
||||
else
|
||||
stops.push({ offset: 1, color: c });
|
||||
const g: QrGradient = {
|
||||
...recipe.dotsOptions!.gradient!,
|
||||
colorStops: stops,
|
||||
};
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
gradient: g,
|
||||
};
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
gradient: g,
|
||||
};
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
gradient: g,
|
||||
};
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
@@ -583,9 +765,17 @@ export function Editor({ id }: EditorProps) {
|
||||
onChange={(c) => {
|
||||
const r = { ...recipe };
|
||||
r.dotsOptions = { ...r.dotsOptions, color: c };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, color: c };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, color: c };
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
color: c,
|
||||
};
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
color: c,
|
||||
};
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -593,7 +783,11 @@ export function Editor({ id }: EditorProps) {
|
||||
Background
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={recipe.backgroundOptions?.gradient ? 'gradient' : 'solid'}
|
||||
value={
|
||||
recipe.backgroundOptions?.gradient
|
||||
? 'gradient'
|
||||
: 'solid'
|
||||
}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
if (v === 'gradient') {
|
||||
@@ -601,7 +795,8 @@ export function Editor({ id }: EditorProps) {
|
||||
...r.backgroundOptions,
|
||||
gradient: makeGradient(
|
||||
'linear',
|
||||
recipe.backgroundOptions?.color ?? '#ffffff',
|
||||
recipe.backgroundOptions?.color ??
|
||||
'#ffffff',
|
||||
'#e0e0e0',
|
||||
0,
|
||||
),
|
||||
@@ -611,7 +806,9 @@ export function Editor({ id }: EditorProps) {
|
||||
r.backgroundOptions = {
|
||||
...r.backgroundOptions,
|
||||
gradient: undefined,
|
||||
color: recipe.backgroundOptions?.color ?? '#ffffff',
|
||||
color:
|
||||
recipe.backgroundOptions?.color ??
|
||||
'#ffffff',
|
||||
};
|
||||
}
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
@@ -631,66 +828,123 @@ export function Editor({ id }: EditorProps) {
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
{ value: 'radial', label: 'Radial' },
|
||||
]}
|
||||
value={recipe.backgroundOptions.gradient.type}
|
||||
value={
|
||||
recipe.backgroundOptions.gradient.type
|
||||
}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
r.backgroundOptions = {
|
||||
...r.backgroundOptions,
|
||||
gradient: {
|
||||
...recipe.backgroundOptions!.gradient!,
|
||||
type: (v as 'linear' | 'radial') ?? 'linear',
|
||||
...recipe.backgroundOptions!
|
||||
.gradient!,
|
||||
type:
|
||||
(v as
|
||||
| 'linear'
|
||||
| 'radial') ?? 'linear',
|
||||
},
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Rotation (°)"
|
||||
min={0}
|
||||
max={360}
|
||||
value={recipe.backgroundOptions.gradient.rotation ?? 0}
|
||||
value={
|
||||
recipe.backgroundOptions.gradient
|
||||
.rotation ?? 0
|
||||
}
|
||||
onChange={(n) => {
|
||||
const r = { ...recipe };
|
||||
r.backgroundOptions = {
|
||||
...r.backgroundOptions,
|
||||
gradient: {
|
||||
...recipe.backgroundOptions!.gradient!,
|
||||
rotation: typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0,
|
||||
...recipe.backgroundOptions!
|
||||
.gradient!,
|
||||
rotation:
|
||||
typeof n === 'string'
|
||||
? parseInt(n, 10) || 0
|
||||
: (n ?? 0),
|
||||
},
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<ColorInput
|
||||
label="Start color"
|
||||
value={recipe.backgroundOptions.gradient.colorStops[0]?.color ?? '#ffffff'}
|
||||
value={
|
||||
recipe.backgroundOptions.gradient
|
||||
.colorStops[0]?.color ?? '#ffffff'
|
||||
}
|
||||
onChange={(c) => {
|
||||
const r = { ...recipe };
|
||||
const stops = [...(recipe.backgroundOptions!.gradient!.colorStops || [])];
|
||||
if (stops[0]) stops[0] = { ...stops[0], color: c };
|
||||
else stops.unshift({ offset: 0, color: c });
|
||||
const stops = [
|
||||
...(recipe.backgroundOptions!
|
||||
.gradient!.colorStops || []),
|
||||
];
|
||||
if (stops[0])
|
||||
stops[0] = {
|
||||
...stops[0],
|
||||
color: c,
|
||||
};
|
||||
else
|
||||
stops.unshift({
|
||||
offset: 0,
|
||||
color: c,
|
||||
});
|
||||
r.backgroundOptions = {
|
||||
...r.backgroundOptions,
|
||||
gradient: { ...recipe.backgroundOptions!.gradient!, colorStops: stops },
|
||||
gradient: {
|
||||
...recipe.backgroundOptions!
|
||||
.gradient!,
|
||||
colorStops: stops,
|
||||
},
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ColorInput
|
||||
label="End color"
|
||||
value={recipe.backgroundOptions.gradient.colorStops[1]?.color ?? recipe.backgroundOptions.gradient.colorStops[0]?.color ?? '#e0e0e0'}
|
||||
value={
|
||||
recipe.backgroundOptions.gradient
|
||||
.colorStops[1]?.color ??
|
||||
recipe.backgroundOptions.gradient
|
||||
.colorStops[0]?.color ??
|
||||
'#e0e0e0'
|
||||
}
|
||||
onChange={(c) => {
|
||||
const r = { ...recipe };
|
||||
const stops = [...(recipe.backgroundOptions!.gradient!.colorStops || [])];
|
||||
if (stops[1]) stops[1] = { ...stops[1], color: c };
|
||||
else stops.push({ offset: 1, color: c });
|
||||
const stops = [
|
||||
...(recipe.backgroundOptions!
|
||||
.gradient!.colorStops || []),
|
||||
];
|
||||
if (stops[1])
|
||||
stops[1] = {
|
||||
...stops[1],
|
||||
color: c,
|
||||
};
|
||||
else
|
||||
stops.push({ offset: 1, color: c });
|
||||
r.backgroundOptions = {
|
||||
...r.backgroundOptions,
|
||||
gradient: { ...recipe.backgroundOptions!.gradient!, colorStops: stops },
|
||||
gradient: {
|
||||
...recipe.backgroundOptions!
|
||||
.gradient!,
|
||||
colorStops: stops,
|
||||
},
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
@@ -701,8 +955,13 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.backgroundOptions?.color ?? '#ffffff'}
|
||||
onChange={(c) => {
|
||||
const r = { ...recipe };
|
||||
r.backgroundOptions = { ...r.backgroundOptions, color: c };
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
r.backgroundOptions = {
|
||||
...r.backgroundOptions,
|
||||
color: c,
|
||||
};
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -712,7 +971,10 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.dotsOptions?.type ?? 'square'}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
r.dotsOptions = { ...r.dotsOptions, type: v ?? 'square' };
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
type: v ?? 'square',
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
}}
|
||||
/>
|
||||
@@ -721,7 +983,10 @@ export function Editor({ id }: EditorProps) {
|
||||
checked={recipe.dotsOptions?.roundSize ?? false}
|
||||
onChange={(e) => {
|
||||
const r = { ...recipe };
|
||||
r.dotsOptions = { ...r.dotsOptions, roundSize: e.currentTarget.checked };
|
||||
r.dotsOptions = {
|
||||
...r.dotsOptions,
|
||||
roundSize: e.currentTarget.checked,
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
}}
|
||||
/>
|
||||
@@ -731,17 +996,27 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.cornersSquareOptions?.type ?? 'square'}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
r.cornersSquareOptions = { ...r.cornersSquareOptions, type: v ?? 'square' };
|
||||
r.cornersSquareOptions = {
|
||||
...r.cornersSquareOptions,
|
||||
type: v ?? 'square',
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Corner dot style"
|
||||
data={CORNER_TYPES}
|
||||
value={recipe.cornersDotOptions?.type ?? recipe.cornersSquareOptions?.type ?? 'square'}
|
||||
value={
|
||||
recipe.cornersDotOptions?.type ??
|
||||
recipe.cornersSquareOptions?.type ??
|
||||
'square'
|
||||
}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
r.cornersDotOptions = { ...r.cornersDotOptions, type: v ?? 'square' };
|
||||
r.cornersDotOptions = {
|
||||
...r.cornersDotOptions,
|
||||
type: v ?? 'square',
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
}}
|
||||
/>
|
||||
@@ -766,8 +1041,13 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.margin ?? 0}
|
||||
onChange={(n) => {
|
||||
const r = { ...recipe };
|
||||
r.margin = typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0;
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
r.margin =
|
||||
typeof n === 'string'
|
||||
? parseInt(n, 10) || 0
|
||||
: (n ?? 0);
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
@@ -779,9 +1059,14 @@ export function Editor({ id }: EditorProps) {
|
||||
const r = { ...recipe };
|
||||
r.backgroundOptions = {
|
||||
...r.backgroundOptions,
|
||||
round: typeof n === 'string' ? parseInt(n, 10) || 0 : n ?? 0,
|
||||
round:
|
||||
typeof n === 'string'
|
||||
? parseInt(n, 10) || 0
|
||||
: (n ?? 0),
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
updateProject({
|
||||
recipeJson: JSON.stringify(r),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
@@ -791,7 +1076,10 @@ export function Editor({ id }: EditorProps) {
|
||||
value={recipe.qrOptions?.errorCorrectionLevel ?? 'M'}
|
||||
onChange={(v) => {
|
||||
const r = { ...recipe };
|
||||
r.qrOptions = { ...r.qrOptions, errorCorrectionLevel: v ?? 'M' };
|
||||
r.qrOptions = {
|
||||
...r.qrOptions,
|
||||
errorCorrectionLevel: v ?? 'M',
|
||||
};
|
||||
updateProject({ recipeJson: JSON.stringify(r) });
|
||||
}}
|
||||
/>
|
||||
@@ -822,11 +1110,15 @@ export function Editor({ id }: EditorProps) {
|
||||
centered
|
||||
>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
This cannot be undone. The project "{project.name || 'Untitled QR'}" will be
|
||||
permanently deleted.
|
||||
This cannot be undone. The project "
|
||||
{project.name || 'Untitled QR'}" will be permanently
|
||||
deleted.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="subtle" onClick={() => setDeleteConfirmOpen(false)}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setDeleteConfirmOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDeleteProject}>
|
||||
@@ -837,4 +1129,3 @@ export function Editor({ id }: EditorProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,12 @@ interface ExportPanelProps {
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelProps) {
|
||||
export function ExportPanel({
|
||||
data,
|
||||
recipe,
|
||||
logoUrl,
|
||||
projectName,
|
||||
}: ExportPanelProps) {
|
||||
const qrRef = useRef<QRCodeStyling | null>(null);
|
||||
|
||||
const getQrInstance = useCallback(() => {
|
||||
@@ -27,10 +32,14 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
|
||||
image: logoUrl || undefined,
|
||||
});
|
||||
if (qrRef.current) {
|
||||
qrRef.current.update(opts as Parameters<QRCodeStyling['update']>[0]);
|
||||
qrRef.current.update(
|
||||
opts as Parameters<QRCodeStyling['update']>[0],
|
||||
);
|
||||
return qrRef.current;
|
||||
}
|
||||
const qr = new QRCodeStyling(opts as ConstructorParameters<typeof QRCodeStyling>[0]);
|
||||
const qr = new QRCodeStyling(
|
||||
opts as ConstructorParameters<typeof QRCodeStyling>[0],
|
||||
);
|
||||
qrRef.current = qr;
|
||||
return qr;
|
||||
}, [data, recipe, logoUrl]);
|
||||
@@ -48,7 +57,10 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `qr-${projectName || 'export'}.svg`.replace(/[^a-z0-9.-]/gi, '-');
|
||||
a.download = `qr-${projectName || 'export'}.svg`.replace(
|
||||
/[^a-z0-9.-]/gi,
|
||||
'-',
|
||||
);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [getQrInstance, projectName]);
|
||||
@@ -61,7 +73,10 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `qr-${projectName || 'export'}.png`.replace(/[^a-z0-9.-]/gi, '-');
|
||||
a.download = `qr-${projectName || 'export'}.png`.replace(
|
||||
/[^a-z0-9.-]/gi,
|
||||
'-',
|
||||
);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [getQrInstance, projectName]);
|
||||
@@ -92,10 +107,15 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
|
||||
page.drawText(urlText, { x: 50, y: 60, size: 10 });
|
||||
}
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const url = URL.createObjectURL(new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }));
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }),
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `qr-${projectName || 'export'}.pdf`.replace(/[^a-z0-9.-]/gi, '-');
|
||||
a.download = `qr-${projectName || 'export'}.pdf`.replace(
|
||||
/[^a-z0-9.-]/gi,
|
||||
'-',
|
||||
);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [getQrInstance, data, projectName]);
|
||||
@@ -106,13 +126,28 @@ export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelP
|
||||
Export
|
||||
</Text>
|
||||
<Group>
|
||||
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handleSvg}>
|
||||
<Button
|
||||
leftSection={<IconDownload size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={handleSvg}
|
||||
>
|
||||
SVG
|
||||
</Button>
|
||||
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handlePng}>
|
||||
<Button
|
||||
leftSection={<IconDownload size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={handlePng}
|
||||
>
|
||||
PNG
|
||||
</Button>
|
||||
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handlePdf}>
|
||||
<Button
|
||||
leftSection={<IconDownload size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={handlePdf}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -12,7 +12,12 @@ interface QrPreviewProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function QrPreview({ data, recipe, logoUrl, size = 256 }: QrPreviewProps) {
|
||||
export function QrPreview({
|
||||
data,
|
||||
recipe,
|
||||
logoUrl,
|
||||
size = 256,
|
||||
}: QrPreviewProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const qrRef = useRef<QRCodeStyling | null>(null);
|
||||
|
||||
|
||||
@@ -56,7 +56,9 @@ export function Sidebar() {
|
||||
deleteFolder,
|
||||
} = useProjects();
|
||||
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set());
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
const [editingFolderId, setEditingFolderId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
@@ -74,10 +76,13 @@ export function Sidebar() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, projectId: string) => {
|
||||
e.dataTransfer.setData('application/x-project-id', projectId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}, []);
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, projectId: string) => {
|
||||
e.dataTransfer.setData('application/x-project-id', projectId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, zoneId: string) => {
|
||||
e.preventDefault();
|
||||
@@ -93,15 +98,20 @@ export function Sidebar() {
|
||||
(e: React.DragEvent, targetFolderId: string | null) => {
|
||||
e.preventDefault();
|
||||
setDragOverId(null);
|
||||
const projectId = e.dataTransfer.getData('application/x-project-id');
|
||||
const projectId = e.dataTransfer.getData(
|
||||
'application/x-project-id',
|
||||
);
|
||||
if (!projectId) return;
|
||||
const fid = targetFolderId === UNCATEGORIZED_ID ? null : targetFolderId;
|
||||
const fid =
|
||||
targetFolderId === UNCATEGORIZED_ID ? null : targetFolderId;
|
||||
moveProjectToFolder(projectId, fid);
|
||||
},
|
||||
[moveProjectToFolder],
|
||||
);
|
||||
|
||||
const uncategorized = projects.filter((p) => !p.folderId || p.folderId === '');
|
||||
const uncategorized = projects.filter(
|
||||
(p) => !p.folderId || p.folderId === '',
|
||||
);
|
||||
const projectsByFolder = folders.map((f) => ({
|
||||
folder: f,
|
||||
projects: projects.filter((p) => p.folderId === f.id),
|
||||
@@ -191,7 +201,10 @@ export function Sidebar() {
|
||||
leftSection={<IconFolderPlus size={16} />}
|
||||
onClick={() => {
|
||||
createFolder().then((folder) => {
|
||||
if (folder) setExpandedIds((prev) => new Set([...prev, folder.id]));
|
||||
if (folder)
|
||||
setExpandedIds(
|
||||
(prev) => new Set([...prev, folder.id]),
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -205,7 +218,13 @@ export function Sidebar() {
|
||||
'Uncategorized',
|
||||
null,
|
||||
<>
|
||||
<Text size="xs" c="dimmed" fw={500} mb={4} className={classes.sectionLabel}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={500}
|
||||
mb={4}
|
||||
className={classes.sectionLabel}
|
||||
>
|
||||
Uncategorized
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
@@ -213,79 +232,95 @@ export function Sidebar() {
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{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}
|
||||
@@ -296,9 +331,11 @@ export function Sidebar() {
|
||||
{folderToDelete && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
This folder contains {folderToDelete.projectCount} project
|
||||
{folderToDelete.projectCount === 1 ? '' : 's'}. They will be moved to
|
||||
Uncategorized. Delete folder "{folderToDelete.folder.name}"?
|
||||
This folder contains {folderToDelete.projectCount}{' '}
|
||||
project
|
||||
{folderToDelete.projectCount === 1 ? '' : 's'}. They
|
||||
will be moved to Uncategorized. Delete folder "
|
||||
{folderToDelete.folder.name}"?
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button
|
||||
|
||||
@@ -11,7 +11,10 @@ interface ProjectsContextValue {
|
||||
refetch: () => void;
|
||||
updateProjectInList: (id: string, patch: Partial<ProjectItem>) => void;
|
||||
removeProjectFromList: (id: string) => void;
|
||||
moveProjectToFolder: (projectId: string, folderId: string | null) => Promise<void>;
|
||||
moveProjectToFolder: (
|
||||
projectId: string,
|
||||
folderId: string | null,
|
||||
) => Promise<void>;
|
||||
createFolder: (name?: string) => Promise<FolderItem | null>;
|
||||
updateFolder: (id: string, patch: Partial<FolderItem>) => Promise<void>;
|
||||
deleteFolder: (id: string) => Promise<void>;
|
||||
@@ -27,11 +30,7 @@ export function useProjects(): ProjectsContextValue {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ProjectsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function ProjectsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [projects, setProjects] = useState<ProjectItem[]>([]);
|
||||
const [folders, setFolders] = useState<FolderItem[]>([]);
|
||||
|
||||
@@ -50,11 +49,14 @@ export function ProjectsProvider({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateProjectInList = useCallback((id: string, patch: Partial<ProjectItem>) => {
|
||||
setProjects((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, ...patch } : p)),
|
||||
);
|
||||
}, []);
|
||||
const updateProjectInList = useCallback(
|
||||
(id: string, patch: Partial<ProjectItem>) => {
|
||||
setProjects((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, ...patch } : p)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeProjectFromList = useCallback((id: string) => {
|
||||
setProjects((prev) => prev.filter((p) => p.id !== id));
|
||||
@@ -81,22 +83,29 @@ export function ProjectsProvider({
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const folder = await res.json();
|
||||
setFolders((prev) => [...prev, folder].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
setFolders((prev) =>
|
||||
[...prev, folder].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
);
|
||||
return folder;
|
||||
}, []);
|
||||
|
||||
const updateFolder = useCallback(async (id: string, patch: Partial<FolderItem>) => {
|
||||
const 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 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' });
|
||||
|
||||
166
qr-web/src/lib/qrStylingOptions.test.ts
Normal file
166
qr-web/src/lib/qrStylingOptions.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildQrStylingOptions,
|
||||
makeGradient,
|
||||
type QrStylingOverrides,
|
||||
} from './qrStylingOptions';
|
||||
describe('buildQrStylingOptions', () => {
|
||||
it('uses recipe defaults when minimal recipe', () => {
|
||||
const opts = buildQrStylingOptions({});
|
||||
expect(opts.width).toBe(256);
|
||||
expect(opts.height).toBe(256);
|
||||
expect(opts.data).toBe(' ');
|
||||
expect(opts.shape).toBe('square');
|
||||
expect(opts.margin).toBe(0);
|
||||
expect(opts.qrOptions).toEqual({
|
||||
type: 'canvas',
|
||||
mode: 'Byte',
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
expect((opts.backgroundOptions as { color: string }).color).toBe(
|
||||
'#ffffff',
|
||||
);
|
||||
expect((opts.dotsOptions as { type: string }).type).toBe('square');
|
||||
expect((opts.cornersSquareOptions as { type: string }).type).toBe(
|
||||
'square',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses overrides for width, height, data', () => {
|
||||
const overrides: QrStylingOverrides = {
|
||||
width: 100,
|
||||
height: 200,
|
||||
data: 'https://x.com',
|
||||
};
|
||||
const opts = buildQrStylingOptions({}, overrides);
|
||||
expect(opts.width).toBe(100);
|
||||
expect(opts.height).toBe(200);
|
||||
expect(opts.data).toBe('https://x.com');
|
||||
});
|
||||
|
||||
it('includes gradient in backgroundOptions when set', () => {
|
||||
const gradient = {
|
||||
type: 'linear' as const,
|
||||
rotation: 90,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#f00' },
|
||||
{ offset: 1, color: '#00f' },
|
||||
],
|
||||
};
|
||||
const opts = buildQrStylingOptions({
|
||||
backgroundOptions: { color: '#fff', gradient },
|
||||
});
|
||||
expect(
|
||||
(opts.backgroundOptions as { gradient: unknown }).gradient,
|
||||
).toEqual(gradient);
|
||||
});
|
||||
|
||||
it('includes gradient in dotsOptions and cornersSquareOptions when set', () => {
|
||||
const gradient = {
|
||||
type: 'radial' as const,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#000' },
|
||||
{ offset: 1, color: '#fff' },
|
||||
],
|
||||
};
|
||||
const opts = buildQrStylingOptions({
|
||||
dotsOptions: { type: 'rounded', color: '#000', gradient },
|
||||
cornersSquareOptions: { type: 'dot', color: '#000', gradient },
|
||||
});
|
||||
expect((opts.dotsOptions as { gradient: unknown }).gradient).toEqual(
|
||||
gradient,
|
||||
);
|
||||
expect(
|
||||
(opts.cornersSquareOptions as { gradient: unknown }).gradient,
|
||||
).toEqual(gradient);
|
||||
});
|
||||
|
||||
it('falls back cornersDotOptions to cornersSquareOptions', () => {
|
||||
const opts = buildQrStylingOptions({
|
||||
cornersSquareOptions: { type: 'extra-rounded', color: '#111' },
|
||||
});
|
||||
expect((opts.cornersDotOptions as { type: string }).type).toBe(
|
||||
'extra-rounded',
|
||||
);
|
||||
expect((opts.cornersDotOptions as { color: string }).color).toBe(
|
||||
'#111',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses cornersDotOptions gradient when both set', () => {
|
||||
const g1 = {
|
||||
type: 'linear' as const,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#a' },
|
||||
{ offset: 1, color: '#b' },
|
||||
],
|
||||
};
|
||||
const g2 = {
|
||||
type: 'radial' as const,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#c' },
|
||||
{ offset: 1, color: '#d' },
|
||||
],
|
||||
};
|
||||
const opts = buildQrStylingOptions({
|
||||
cornersSquareOptions: { gradient: g1 },
|
||||
cornersDotOptions: { gradient: g2 },
|
||||
});
|
||||
expect(
|
||||
(opts.cornersDotOptions as { gradient: unknown }).gradient,
|
||||
).toEqual(g2);
|
||||
});
|
||||
|
||||
it('falls back cornersDotOptions gradient to cornersSquareOptions', () => {
|
||||
const g = {
|
||||
type: 'linear' as const,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#0' },
|
||||
{ offset: 1, color: '#1' },
|
||||
],
|
||||
};
|
||||
const opts = buildQrStylingOptions({
|
||||
cornersSquareOptions: { type: 'dot', gradient: g },
|
||||
});
|
||||
expect(
|
||||
(opts.cornersDotOptions as { gradient: unknown }).gradient,
|
||||
).toEqual(g);
|
||||
});
|
||||
|
||||
it('uses imageOptions and shape from recipe', () => {
|
||||
const opts = buildQrStylingOptions({
|
||||
imageOptions: {
|
||||
hideBackgroundDots: false,
|
||||
imageSize: 0.5,
|
||||
margin: 5,
|
||||
},
|
||||
shape: 'circle',
|
||||
});
|
||||
expect(
|
||||
(opts.imageOptions as { hideBackgroundDots: boolean })
|
||||
.hideBackgroundDots,
|
||||
).toBe(false);
|
||||
expect((opts.imageOptions as { imageSize: number }).imageSize).toBe(
|
||||
0.5,
|
||||
);
|
||||
expect((opts.imageOptions as { margin: number }).margin).toBe(5);
|
||||
expect(opts.shape).toBe('circle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeGradient', () => {
|
||||
it('returns linear gradient with rotation', () => {
|
||||
const g = makeGradient('linear', '#f00', '#0f0', 45);
|
||||
expect(g.type).toBe('linear');
|
||||
expect(g.rotation).toBe(45);
|
||||
expect(g.colorStops).toHaveLength(2);
|
||||
expect(g.colorStops[0]).toEqual({ offset: 0, color: '#f00' });
|
||||
expect(g.colorStops[1]).toEqual({ offset: 1, color: '#0f0' });
|
||||
});
|
||||
|
||||
it('defaults rotation to 0', () => {
|
||||
const g = makeGradient('radial', '#000', '#fff');
|
||||
expect(g.type).toBe('radial');
|
||||
expect(g.rotation).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,10 @@ export function buildQrStylingOptions(
|
||||
|
||||
const cornersDot = recipe.cornersDotOptions;
|
||||
opts.cornersDotOptions = {
|
||||
type: (cornersDot?.type as CornerType) ?? (cornersSq?.type as CornerType) ?? 'square',
|
||||
type:
|
||||
(cornersDot?.type as CornerType) ??
|
||||
(cornersSq?.type as CornerType) ??
|
||||
'square',
|
||||
color: cornersDot?.color ?? cornersSq?.color ?? '#000000',
|
||||
...((cornersDot?.gradient ?? cornersSq?.gradient) && {
|
||||
gradient: cornersDot?.gradient ?? cornersSq?.gradient,
|
||||
|
||||
13
qr-web/src/types/project.test.ts
Normal file
13
qr-web/src/types/project.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DEFAULT_RECIPE } from './project';
|
||||
|
||||
describe('project types', () => {
|
||||
it('DEFAULT_RECIPE has expected shape', () => {
|
||||
expect(DEFAULT_RECIPE.width).toBe(300);
|
||||
expect(DEFAULT_RECIPE.height).toBe(300);
|
||||
expect(DEFAULT_RECIPE.qrOptions?.errorCorrectionLevel).toBe('M');
|
||||
expect(DEFAULT_RECIPE.backgroundOptions?.color).toBe('#ffffff');
|
||||
expect(DEFAULT_RECIPE.dotsOptions?.color).toBe('#000000');
|
||||
expect(DEFAULT_RECIPE.cornersSquareOptions?.type).toBe('square');
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,11 @@ export interface RecipeOptions {
|
||||
contentType?: ContentType;
|
||||
image?: string;
|
||||
qrOptions?: { type?: string; mode?: string; errorCorrectionLevel?: string };
|
||||
imageOptions?: { hideBackgroundDots?: boolean; imageSize?: number; margin?: number };
|
||||
imageOptions?: {
|
||||
hideBackgroundDots?: boolean;
|
||||
imageSize?: number;
|
||||
margin?: number;
|
||||
};
|
||||
backgroundOptions?: {
|
||||
color?: string;
|
||||
gradient?: QrGradient;
|
||||
@@ -39,8 +43,16 @@ export interface RecipeOptions {
|
||||
gradient?: QrGradient;
|
||||
roundSize?: boolean;
|
||||
};
|
||||
cornersSquareOptions?: { color?: string; type?: string; gradient?: QrGradient };
|
||||
cornersDotOptions?: { color?: string; type?: string; gradient?: QrGradient };
|
||||
cornersSquareOptions?: {
|
||||
color?: string;
|
||||
type?: string;
|
||||
gradient?: QrGradient;
|
||||
};
|
||||
cornersDotOptions?: {
|
||||
color?: string;
|
||||
type?: string;
|
||||
gradient?: QrGradient;
|
||||
};
|
||||
shape?: 'square' | 'circle';
|
||||
margin?: number;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,23 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,18 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
globals: false,
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov'],
|
||||
include: ['src/lib/**/*.ts', 'src/types/**/*.ts'],
|
||||
exclude: ['src/**/*.test.{ts,tsx}'],
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
|
||||
Reference in New Issue
Block a user