'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: (
URL
),
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: (
Text
),
placeholder: 'Any text, message, or text-based data',
inputLabel: 'Text content',
validate: (v) => (!v.trim() ? 'Enter some text' : null),
},
{
value: 'email',
label: (
Email
),
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: (
Phone
),
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(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState(null);
const [error, setError] = useState(null);
const [contentTouched, setContentTouched] = useState(false);
const pendingRef = useRef | 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) => {
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) => {
saveProject(patch);
}, 600);
const updateProject = useCallback(
(patch: Partial) => {
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 = {
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 = {
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 (
);
}
if (error || !project) {
return (
{error ?? 'Project not found'}
);
}
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 (
{saving
? 'Saving…'
: lastSaved
? `Saved ${lastSaved.toLocaleTimeString()}`
: ''}
setDeleteConfirmOpen(true)}
aria-label="Delete project"
>
updateProject({ name: e.target.value })
}
/>
Content type
setContentType(v as ContentType)}
data={CONTENT_TYPES.map((t) => ({
value: t.value,
label: t.label,
}))}
fullWidth
/>
setContent(e.target.value)}
onBlur={() => setContentTouched(true)}
/>
{isUrl && (
<>
{
const checked = e.currentTarget.checked;
updateProject({
shortenEnabled: checked,
});
if (checked && project.originalUrl)
handleShorten();
}}
/>
{project.shortenEnabled && project.shortUrl && (
Short URL: {project.shortUrl}
)}
>
)}
Drop logo image here (PNG, WebP, SVG, etc.)
{
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),
});
}}
/>
{
const r = { ...recipe };
r.imageOptions = {
...r.imageOptions,
hideBackgroundDots: e.currentTarget.checked,
};
updateProject({
recipeJson: JSON.stringify(r),
});
}}
/>
Foreground
{
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 ? (
{
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),
});
}}
/>
{
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),
});
}}
/>
{
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 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 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),
});
}}
/>
)}
Background
{
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 ? (
{
const r = { ...recipe };
r.backgroundOptions = {
...r.backgroundOptions,
gradient: {
...recipe.backgroundOptions!
.gradient!,
type:
(v as
| 'linear'
| 'radial') ?? 'linear',
},
};
updateProject({
recipeJson: JSON.stringify(r),
});
}}
/>
{
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),
});
}}
/>
{
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),
});
}}
/>
{
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),
});
}}
/>
) : (
{
const r = { ...recipe };
r.backgroundOptions = {
...r.backgroundOptions,
color: c,
};
updateProject({
recipeJson: JSON.stringify(r),
});
}}
/>
)}
{
const r = { ...recipe };
r.dotsOptions = {
...r.dotsOptions,
type: v ?? 'square',
};
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
{
const r = { ...recipe };
r.dotsOptions = {
...r.dotsOptions,
roundSize: e.currentTarget.checked,
};
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
{
const r = { ...recipe };
r.cornersSquareOptions = {
...r.cornersSquareOptions,
type: v ?? 'square',
};
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
{
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 */}
{
const r = { ...recipe };
r.margin =
typeof n === 'string'
? parseInt(n, 10) || 0
: (n ?? 0);
updateProject({
recipeJson: JSON.stringify(r),
});
}}
/>
{
const r = { ...recipe };
r.backgroundOptions = {
...r.backgroundOptions,
round:
typeof n === 'string'
? parseInt(n, 10) || 0
: (n ?? 0),
};
updateProject({
recipeJson: JSON.stringify(r),
});
}}
/>
{
const r = { ...recipe };
r.qrOptions = {
...r.qrOptions,
errorCorrectionLevel: v ?? 'M',
};
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
setDeleteConfirmOpen(false)}
title="Delete project?"
centered
>
This cannot be undone. The project "
{project.name || 'Untitled QR'}" will be permanently
deleted.
setDeleteConfirmOpen(false)}
>
Cancel
Delete project
);
}