Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/src/app/api/forms/[slug]/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ export async function POST(

const allowed = field.validation.allowedMimeTypes;
const mime = file.type || "application/octet-stream";
const wantsImage = field.type === "image_upload" || field.type === "signature";
const mimeOk = allowed?.length
? allowed.includes(mime)
: field.type === "image_upload"
: wantsImage
? mime.startsWith("image/")
: true;
if (!mimeOk) {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/f/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export default async function PublicFormPage({
fileUploading: t.fileUploading,
fileRemove: t.fileRemove,
uploadFailed: t.uploadFailed,
signatureClear: t.signatureClear,
}}
/>
</Shell>
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/form/field-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { FileField, type FileFieldLabels } from "./file-field";
import { MatrixField } from "./matrix-field";
import { ScaleButtons, SliderInput, StarRating } from "./rating-fields";
import { SignatureField } from "./signature-field";

export type FieldValue =
| string
Expand Down Expand Up @@ -149,6 +150,18 @@ export function FieldInput({
/>
);

case "signature":
return (
<SignatureField
slug={slug}
field={field}
value={value as FileAnswer | undefined}
disabled={disabled}
labels={fileLabels}
onChange={onChange}
/>
);

case "single_choice":
return (
<RadioGroup
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/form/file-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface FileFieldLabels {
uploading: string;
remove: string;
uploadFailed: string;
/** Used by the signature pad's clear button. */
clear: string;
}

function formatSize(bytes: number): string {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/form/form-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface FormLabels {
fileUploading: string;
fileRemove: string;
uploadFailed: string;
signatureClear: string;
}

export function FormRenderer({
Expand Down Expand Up @@ -140,6 +141,7 @@ export function FormRenderer({
uploading: labels.fileUploading,
remove: labels.fileRemove,
uploadFailed: labels.uploadFailed,
clear: labels.signatureClear,
}}
/>
</Field>
Expand Down
132 changes: 132 additions & 0 deletions apps/web/src/components/form/signature-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use client";

import type { FileAnswer, FormField } from "@msk-forms/shared";
import { useRef, useState } from "react";

import type { FileFieldLabels } from "./file-field";

/**
* Signature pad: the applicant draws on a canvas; on each completed stroke the
* drawing is exported to a PNG and uploaded through the form's normal upload
* endpoint (server → MinIO), so the answer is a `FileAnswer` like any file field.
*/
export function SignatureField({
slug,
field,
value,
onChange,
disabled,
labels,
}: {
slug: string;
field: FormField;
value: FileAnswer | undefined;
onChange: (value: FileAnswer | undefined) => void;
disabled?: boolean;
labels: FileFieldLabels;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawing = useRef(false);
const dirty = useRef(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);

function pos(e: React.PointerEvent<HTMLCanvasElement>) {
const rect = e.currentTarget.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}

function start(e: React.PointerEvent<HTMLCanvasElement>) {
if (disabled) return;
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
drawing.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
const { x, y } = pos(e);
ctx.beginPath();
ctx.moveTo(x, y);
}

function move(e: React.PointerEvent<HTMLCanvasElement>) {
if (!drawing.current) return;
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const { x, y } = pos(e);
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.strokeStyle = "#0a0a0a";
ctx.lineTo(x, y);
ctx.stroke();
dirty.current = true;
}

async function end() {
if (!drawing.current) return;
drawing.current = false;
if (dirty.current) await upload();
}

function clear() {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
dirty.current = false;
setError(null);
onChange(undefined);
}

async function upload() {
const canvas = canvasRef.current;
if (!canvas) return;
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) return;

setError(null);
setUploading(true);
try {
const fd = new FormData();
fd.set("file", new File([blob], "signature.png", { type: "image/png" }));
fd.set("fieldId", field.id);
const res = await fetch(`/api/forms/${slug}/upload`, { method: "POST", body: fd });
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error ?? labels.uploadFailed);
}
const data = (await res.json()) as FileAnswer;
onChange({ key: data.key, name: data.name, size: data.size, mime: data.mime });
} catch (err) {
setError(err instanceof Error ? err.message : labels.uploadFailed);
onChange(undefined);
} finally {
setUploading(false);
}
}

return (
<div className="flex flex-col gap-1.5">
<canvas
ref={canvasRef}
width={500}
height={160}
onPointerDown={start}
onPointerMove={move}
onPointerUp={end}
onPointerLeave={end}
className="h-40 w-full touch-none rounded-md border border-input bg-white"
/>
<div className="flex items-center gap-3 text-xs">
<button
type="button"
onClick={clear}
disabled={disabled || uploading}
className="font-medium text-muted-foreground transition-colors hover:text-destructive disabled:opacity-50"
>
{labels.clear}
</button>
{uploading && <span className="text-muted-foreground">{labels.uploading}</span>}
{!uploading && value && <span className="text-primary">✓</span>}
{error && <span className="text-destructive">{error}</span>}
</div>
</div>
);
}
6 changes: 4 additions & 2 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const en = {
submitFailed: "Submission failed.",
captchaRequired: "Please complete the captcha.",
fileUploading: "Uploading…", fileRemove: "Remove", uploadFailed: "Upload failed.",
signatureClear: "Clear",
},
status: {
yourSubmission: "Your submission", activity: "Activity", yourAnswers: "Your answers",
Expand Down Expand Up @@ -139,7 +140,7 @@ const en = {
short_text: "Short text", long_text: "Long text", email: "Email", number: "Number", phone: "Phone", url: "URL",
single_choice: "Single choice", dropdown: "Dropdown", multi_choice: "Multiple choice", yes_no: "Yes / No",
rating_stars: "Star rating", nps: "NPS (0–10)", slider: "Slider", emoji_scale: "Emoji scale", matrix: "Matrix",
date: "Date", file_upload: "File upload", image_upload: "Image upload",
date: "Date", file_upload: "File upload", image_upload: "Image upload", signature: "Signature",
consent: "Consent", heading: "Heading", paragraph: "Paragraph", divider: "Divider",
},
},
Expand Down Expand Up @@ -203,6 +204,7 @@ const de: Dictionary = {
submitFailed: "Senden fehlgeschlagen.",
captchaRequired: "Bitte das Captcha lösen.",
fileUploading: "Wird hochgeladen…", fileRemove: "Entfernen", uploadFailed: "Upload fehlgeschlagen.",
signatureClear: "Löschen",
},
status: {
yourSubmission: "Deine Einreichung", activity: "Aktivität", yourAnswers: "Deine Antworten",
Expand Down Expand Up @@ -294,7 +296,7 @@ const de: Dictionary = {
short_text: "Kurztext", long_text: "Langtext", email: "E-Mail", number: "Zahl", phone: "Telefon", url: "URL",
single_choice: "Einfachauswahl", dropdown: "Dropdown", multi_choice: "Mehrfachauswahl", yes_no: "Ja / Nein",
rating_stars: "Sterne-Bewertung", nps: "NPS (0–10)", slider: "Schieberegler", emoji_scale: "Emoji-Skala", matrix: "Matrix",
date: "Datum", file_upload: "Datei-Upload", image_upload: "Bild-Upload",
date: "Datum", file_upload: "Datei-Upload", image_upload: "Bild-Upload", signature: "Unterschrift",
consent: "Zustimmung", heading: "Überschrift", paragraph: "Absatz", divider: "Trenner",
},
},
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/builder-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const BUILDER_FIELDS: { type: FieldType; label: string }[] = [
{ type: "date", label: "Date" },
{ type: "file_upload", label: "File upload" },
{ type: "image_upload", label: "Image upload" },
{ type: "signature", label: "Signature" },
{ type: "consent", label: "Consent" },
{ type: "heading", label: "Heading" },
{ type: "paragraph", label: "Paragraph" },
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/form-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ describe("buildAnswerSchema", () => {
expect(s.safeParse({}).success).toBe(true);
expect(s.safeParse({ m: { r1: "y" } }).success).toBe(true);
});

it("treats a signature as a file-reference answer", () => {
const s = buildAnswerSchema(specWith({ id: "sig", type: "signature", validation: { required: true } }));
const ok = { sig: { key: "uploads/f/abc", name: "signature.png", size: 120, mime: "image/png" } };
expect(s.safeParse(ok).success).toBe(true);
expect(s.safeParse({ sig: "not-a-file" }).success).toBe(false);
});
});

describe("formatAnswerValue (rating family)", () => {
Expand Down
8 changes: 6 additions & 2 deletions packages/shared/src/form-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,12 @@ export const formSpecSchema = z.object({
});
export type FormSpec = z.infer<typeof formSpecSchema>;

/** Field types whose answer is an uploaded file reference, not a scalar. */
export const FILE_FIELD_TYPES = ["file_upload", "image_upload"] as const;
/**
* Field types whose answer is an uploaded file reference, not a scalar.
* `signature` is included: the canvas drawing is uploaded as a PNG and stored
* exactly like any other file answer.
*/
export const FILE_FIELD_TYPES = ["file_upload", "image_upload", "signature"] as const;

/**
* The answer value for a file field: a reference to an object already uploaded
Expand Down