1120 lines
48 KiB
TypeScript
1120 lines
48 KiB
TypeScript
'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) });
|
||
}}
|
||
/>
|
||
{/* 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 "
|
||
{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>
|
||
);
|
||
}
|