'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 }; 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.cornersSquareOptions = { ...r.cornersSquareOptions, type: v ?? 'square', }; updateProject({ recipeJson: JSON.stringify(r) }); }} /> { const r = { ...recipe }; r.qrOptions = { ...r.qrOptions, errorCorrectionLevel: v ?? 'M', }; updateProject({ recipeJson: JSON.stringify(r) }); }} />
Preview
setDeleteConfirmOpen(false)} title="Delete project?" centered > This cannot be undone. The project " {project.name || 'Untitled QR'}" will be permanently deleted.
); }