Initial commit

This commit is contained in:
2026-02-07 11:03:53 -03:00
commit 84168f6f3c
64 changed files with 11402 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
.root {
display: flex;
gap: 24px;
padding: 16px;
max-width: 1200px;
margin: 0 auto;
}
.editor {
flex: 0 0 360px;
}
.preview {
flex: 1;
min-width: 0;
}
.paper {
background: var(--sidebar-bg);
border-color: var(--border);
}
.center {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}

View File

@@ -0,0 +1,840 @@
'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) });
}}
/>
<Select
label="Shape"
data={[
{ value: 'square', label: 'Square' },
{ value: 'circle', label: 'Circle' },
]}
value={recipe.shape ?? 'square'}
onChange={(v) => {
const r = { ...recipe };
r.shape = (v as 'square' | 'circle') ?? 'square';
updateProject({ recipeJson: JSON.stringify(r) });
}}
/>
<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>
);
}

View File

@@ -0,0 +1,6 @@
.panel {
padding: 16px;
background: var(--sidebar-bg);
border: 1px solid var(--border);
border-radius: 8px;
}

View File

@@ -0,0 +1,113 @@
'use client';
import { useRef, useCallback } from 'react';
import { Button, Group, Stack, Text } from '@mantine/core';
import { IconDownload } from '@tabler/icons-react';
import QRCodeStyling from 'qr-code-styling';
import { PDFDocument } from 'pdf-lib';
import type { RecipeOptions } from '@/types/project';
import { buildQrStylingOptions } from '@/lib/qrStylingOptions';
import classes from './ExportPanel.module.css';
interface ExportPanelProps {
data: string;
recipe: RecipeOptions;
logoUrl?: string | null;
projectName?: string;
}
export function ExportPanel({ data, recipe, logoUrl, projectName }: ExportPanelProps) {
const qrRef = useRef<QRCodeStyling | null>(null);
const getQrInstance = useCallback(() => {
const opts = buildQrStylingOptions(recipe, {
width: 512,
height: 512,
data: data || ' ',
image: logoUrl || undefined,
});
if (qrRef.current) {
qrRef.current.update(opts as Parameters<QRCodeStyling['update']>[0]);
return qrRef.current;
}
const qr = new QRCodeStyling(opts as ConstructorParameters<typeof QRCodeStyling>[0]);
qrRef.current = qr;
return qr;
}, [data, recipe, logoUrl]);
const handleSvg = useCallback(async () => {
const qr = getQrInstance();
const blob = await qr.getRawData('svg');
if (!blob) return;
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.click();
URL.revokeObjectURL(url);
}, [getQrInstance, projectName]);
const handlePng = useCallback(async () => {
const qr = getQrInstance();
const blob = await qr.getRawData('png');
if (!blob) return;
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.click();
URL.revokeObjectURL(url);
}, [getQrInstance, projectName]);
const handlePdf = useCallback(async () => {
const qr = getQrInstance();
const blob = await qr.getRawData('png');
if (!blob) return;
const arrayBuffer = await blob.arrayBuffer();
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([400, 500]);
const pngImage = await pdfDoc.embedPng(new Uint8Array(arrayBuffer));
const scale = Math.min(280 / pngImage.width, 280 / pngImage.height);
const w = pngImage.width * scale;
const h = pngImage.height * scale;
page.drawImage(pngImage, {
x: (400 - w) / 2,
y: (500 - h) / 2,
width: w,
height: h,
});
if (projectName) {
page.drawText(projectName, { x: 50, y: 80, size: 12 });
}
if (data) {
const urlText = data.length > 50 ? data.slice(0, 47) + '...' : data;
page.drawText(urlText, { x: 50, y: 60, size: 10 });
}
const pdfBytes = await pdfDoc.save();
const url = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
const a = document.createElement('a');
a.href = url;
a.download = `qr-${projectName || 'export'}.pdf`.replace(/[^a-z0-9.-]/gi, '-');
a.click();
URL.revokeObjectURL(url);
}, [getQrInstance, data, projectName]);
return (
<Stack gap="md" mt="md" className={classes.panel}>
<Text size="sm" fw={500}>
Export
</Text>
<Group>
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handleSvg}>
SVG
</Button>
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handlePng}>
PNG
</Button>
<Button leftSection={<IconDownload size={16} />} variant="light" size="sm" onClick={handlePdf}>
PDF
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,3 @@
.content {
padding: 16px;
}

View File

@@ -0,0 +1,16 @@
'use client';
import { Stack, Text, Center } from '@mantine/core';
import classes from './ProjectsList.module.css';
export function ProjectsList() {
return (
<div className={classes.content}>
<Stack align="center" gap="md" mt="xl">
<Text c="dimmed" size="sm">
Select a project from the sidebar or create a new one.
</Text>
</Stack>
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { useEffect, useRef } from 'react';
import QRCodeStyling from 'qr-code-styling';
import type { RecipeOptions } from '@/types/project';
import { buildQrStylingOptions } from '@/lib/qrStylingOptions';
interface QrPreviewProps {
data: string;
recipe: RecipeOptions;
logoUrl?: string | null;
size?: number;
}
export function QrPreview({ data, recipe, logoUrl, size = 256 }: QrPreviewProps) {
const ref = useRef<HTMLDivElement>(null);
const qrRef = useRef<QRCodeStyling | null>(null);
useEffect(() => {
if (!ref.current) return;
const qr = new QRCodeStyling(
buildQrStylingOptions(recipe, {
width: size,
height: size,
data: data || ' ',
image: logoUrl || undefined,
}) as ConstructorParameters<typeof QRCodeStyling>[0],
);
qrRef.current = qr;
qr.append(ref.current);
return () => {
ref.current?.replaceChildren();
qrRef.current = null;
};
}, [size]);
useEffect(() => {
const qr = qrRef.current;
if (!qr) return;
qr.update(
buildQrStylingOptions(recipe, {
width: recipe.width ?? size,
height: recipe.height ?? size,
data: data || ' ',
image: logoUrl || undefined,
}) as Parameters<QRCodeStyling['update']>[0],
);
}, [data, recipe, logoUrl, size]);
return <div ref={ref} style={{ display: 'inline-block' }} />;
}

View File

@@ -0,0 +1,64 @@
.sidebar {
height: 100%;
background: var(--sidebar-bg);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.title {
display: flex;
align-items: center;
color: #e6edf3;
}
.nav {
flex: 1;
overflow: auto;
}
.navLink {
border-radius: 6px;
cursor: grab;
}
.navLink:active {
cursor: grabbing;
}
.navLink[data-active] {
background: #21262d;
}
.sectionLabel {
user-select: none;
}
.dropZone {
border-radius: 6px;
padding: 4px 0;
min-height: 24px;
transition: background 0.15s ease;
}
.dropZone[data-dragging] {
background: var(--mantine-color-blue-filled);
opacity: 0.2;
}
.folderHeader {
cursor: pointer;
border-radius: 6px;
padding: 4px 6px;
align-items: center;
user-select: none;
}
.folderHeader:hover {
background: var(--mantine-color-default-hover);
}

View File

@@ -0,0 +1,319 @@
'use client';
import { useState, useCallback } from 'react';
import {
NavLink,
Stack,
Title,
Button,
Box,
Collapse,
TextInput,
ActionIcon,
Group,
Text,
Modal,
} from '@mantine/core';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
IconPlus,
IconQrcode,
IconFolder,
IconFolderOpen,
IconChevronDown,
IconChevronRight,
IconFolderPlus,
IconTrash,
} from '@tabler/icons-react';
import { useProjects } from '@/contexts/ProjectsContext';
import classes from './Sidebar.module.css';
export interface ProjectItem {
id: string;
name: string;
updatedAt: string;
logoUrl: string | null;
folderId?: string | null;
}
export interface FolderItem {
id: string;
name: string;
sortOrder: number;
}
const UNCATEGORIZED_ID = '__uncategorized__';
export function Sidebar() {
const pathname = usePathname();
const {
projects,
folders,
moveProjectToFolder,
createFolder,
updateFolder,
deleteFolder,
} = useProjects();
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('');
const [folderToDelete, setFolderToDelete] = useState<{
folder: FolderItem;
projectCount: number;
} | null>(null);
const toggleFolder = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
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();
e.dataTransfer.dropEffect = 'move';
setDragOverId(zoneId);
}, []);
const handleDragLeave = useCallback(() => {
setDragOverId(null);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent, targetFolderId: string | null) => {
e.preventDefault();
setDragOverId(null);
const projectId = e.dataTransfer.getData('application/x-project-id');
if (!projectId) return;
const fid = targetFolderId === UNCATEGORIZED_ID ? null : targetFolderId;
moveProjectToFolder(projectId, fid);
},
[moveProjectToFolder],
);
const uncategorized = projects.filter((p) => !p.folderId || p.folderId === '');
const projectsByFolder = folders.map((f) => ({
folder: f,
projects: projects.filter((p) => p.folderId === f.id),
}));
const startEditFolder = (folder: FolderItem) => {
setEditingFolderId(folder.id);
setEditingName(folder.name);
};
const saveEditFolder = async () => {
if (editingFolderId && editingName.trim()) {
await updateFolder(editingFolderId, { name: editingName.trim() });
}
setEditingFolderId(null);
setEditingName('');
};
const handleDeleteFolder = useCallback(
(folder: FolderItem, projectCount: number) => {
if (projectCount === 0) {
deleteFolder(folder.id);
} else {
setFolderToDelete({ folder, projectCount });
}
},
[deleteFolder],
);
const confirmDeleteFolder = useCallback(() => {
if (folderToDelete) {
deleteFolder(folderToDelete.folder.id);
setFolderToDelete(null);
}
}, [folderToDelete, deleteFolder]);
const renderProjectLink = (p: ProjectItem) => (
<NavLink
key={p.id}
component={Link}
href={`/projects/${p.id}`}
label={p.name || 'Untitled QR'}
active={pathname === `/projects/${p.id}`}
className={classes.navLink}
draggable
onDragStart={(e) => handleDragStart(e, p.id)}
/>
);
const renderDropZone = (
zoneId: string,
label: string,
folderId: string | null,
children: React.ReactNode,
) => (
<Box
className={classes.dropZone}
data-dragging={dragOverId === zoneId ? true : undefined}
onDragOver={(e) => handleDragOver(e, zoneId)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, folderId)}
>
{children}
</Box>
);
return (
<Stack gap="md" p="md" className={classes.sidebar}>
<div className={classes.header}>
<Title order={4} className={classes.title}>
<IconQrcode size={20} style={{ marginRight: 8 }} />
QR Designer
</Title>
<Group gap={4}>
<Button
component={Link}
href="/projects/new"
leftSection={<IconPlus size={16} />}
size="xs"
variant="light"
>
New
</Button>
<Button
size="xs"
variant="subtle"
leftSection={<IconFolderPlus size={16} />}
onClick={() => {
createFolder().then((folder) => {
if (folder) setExpandedIds((prev) => new Set([...prev, folder.id]));
});
}}
>
Folder
</Button>
</Group>
</div>
<nav className={classes.nav}>
{renderDropZone(
UNCATEGORIZED_ID,
'Uncategorized',
null,
<>
<Text size="xs" c="dimmed" fw={500} mb={4} className={classes.sectionLabel}>
Uncategorized
</Text>
<Stack gap={2}>
{uncategorized.map((p) => renderProjectLink(p))}
</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>
);
})}
</nav>
<Modal
opened={folderToDelete !== null}
onClose={() => setFolderToDelete(null)}
title="Delete folder?"
centered
>
{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 &quot;{folderToDelete.folder.name}&quot;?
</Text>
<Group justify="flex-end" gap="xs">
<Button
variant="subtle"
onClick={() => setFolderToDelete(null)}
>
Cancel
</Button>
<Button color="red" onClick={confirmDeleteFolder}>
Delete folder
</Button>
</Group>
</>
)}
</Modal>
</Stack>
);
}