Files
shorty/qr-web/src/components/Editor.tsx
mifi 1299fc4dd3
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/deploy unknown status
Fixes for API logo issues
2026-02-07 17:27:36 -03:00

1120 lines
48 KiB
TypeScript
Raw Blame History

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