refactor: update Docker base images, enhance image upload security, and implement shared form client logic and validation

This commit is contained in:
2026-08-01 15:57:55 +07:00 Unverified
parent e243a75d33
commit b557879928
14 changed files with 505 additions and 318 deletions
+5 -95
View File
@@ -18,7 +18,7 @@ import {
deleteQuestion,
bulkUpdateQuestions,
} from "@/app/actions/questions";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Image as ImageIcon, X, Check, GripVertical, AlertCircle } from "lucide-react";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Image as ImageIcon, X } from "lucide-react";
import Image from 'next/image';
import { format, parseISO } from "date-fns";
import { DateTimePicker } from "@/components/date-time-picker";
@@ -43,13 +43,10 @@ import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
useDebouncedAutoSave,
type AutoSaveStatus,
} from "@/hooks/use-debounced-autosave";
import { stripHtml } from "@/lib/utils";
import {
@@ -76,6 +73,10 @@ import {
getQuestionResponseType,
type QuestionResponseType,
} from "@/lib/question-response-type";
import {
SaveStatusIndicator,
SortableQuestion,
} from "@/components/admin-form-editor-parts";
interface Question {
id: string;
@@ -765,53 +766,6 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
);
}
function SaveStatusIndicator({
status,
error,
onRetry,
}: {
status: AutoSaveStatus;
error: string | null;
onRetry: () => Promise<void>;
}) {
if (status === "clean") return null;
return (
<span
className="flex items-center gap-1.5 text-xs text-muted-foreground"
aria-live="polite"
>
{status === "dirty" && "Unsaved"}
{status === "saving" && (
<>
<Loader2 className="animate-spin" />
Saving
</>
)}
{status === "saved" && (
<>
<Check />
Saved
</>
)}
{status === "error" && (
<>
<AlertCircle />
<span>{error || "Could not save."}</span>
<Button
variant="link"
size="sm"
className="h-auto px-1"
onClick={() => void onRetry().catch(() => undefined)}
>
Retry
</Button>
</>
)}
</span>
);
}
function questionTypeLabel(type: Question["type"]) {
return {
text: "Short answer",
@@ -820,47 +774,3 @@ function questionTypeLabel(type: Question["type"]) {
checkbox: "Choose multiple",
}[type];
}
function SortableQuestion({
id,
position,
children,
}: {
id: string;
position: number;
children: React.ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
className="relative"
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}}
>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute left-2 top-4"
aria-label={`Drag question ${position} to reorder`}
{...attributes}
{...listeners}
>
<GripVertical />
</Button>
{children}
</div>
);
}