diff --git a/packages/ui-default/components/training/SectionItem.tsx b/packages/ui-default/components/training/SectionItem.tsx new file mode 100644 index 0000000000..99c51673dd --- /dev/null +++ b/packages/ui-default/components/training/SectionItem.tsx @@ -0,0 +1,215 @@ +import { AutoCompleteHandle } from '@hydrooj/components'; +import type { ProblemDoc } from 'hydrooj/src/interface'; +import React from 'react'; +import ProblemSelectAutoComplete from 'vj/components/autocomplete/components/ProblemSelectAutoComplete'; +import Notification from 'vj/components/notification'; +import { i18n } from 'vj/utils'; +import { TrainingNode, wouldCreateCycle } from './types'; + +const PREREQ_COLLAPSE_THRESHOLD = 10; + +interface SectionItemProps { + node: TrainingNode; + index: number; + totalSections: number; + allSections: TrainingNode[]; + defaultCollapsed?: boolean; + onUpdate: (nodeId: number, updates: Partial) => void; + onDelete: (nodeId: number) => void; + onMoveUp: () => void; + onMoveDown: () => void; +} + +function SectionItem({ + node, index, totalSections, allSections, defaultCollapsed = false, onUpdate, onDelete, onMoveUp, onMoveDown, +}: SectionItemProps) { + const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed); + const [isEditingTitle, setIsEditingTitle] = React.useState(false); + const [titleValue, setTitleValue] = React.useState(node.title); + const [prereqExpanded, setPrereqExpanded] = React.useState(false); + const autocompleteRef = React.useRef>(null); + + const handlePidsChange = React.useCallback((val: string) => { + const pids = val.split(',').map((v) => v.trim()).filter((v) => v).map((v) => { + const num = Number.parseInt(v, 10); + return Number.isNaN(num) ? v : num; + }); + onUpdate(node._id, { pids }); + }, [node._id, onUpdate]); + + // Sync title value when node.title changes externally + React.useEffect(() => { + if (!isEditingTitle) setTitleValue(node.title); + }, [node.title, isEditingTitle]); + + const handleTitleSave = React.useCallback(() => { + if (titleValue.trim()) { + onUpdate(node._id, { title: titleValue.trim() }); + } else { + setTitleValue(node.title); + } + setIsEditingTitle(false); + }, [titleValue, node._id, node.title, onUpdate]); + + const handleTitleKeyDown = React.useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleTitleSave(); + if (e.key === 'Escape') { + setTitleValue(node.title); + setIsEditingTitle(false); + } + }, [handleTitleSave, node.title]); + + const handleRequireNidsChange = React.useCallback((sectionId: number, checked: boolean) => { + if (checked && wouldCreateCycle(allSections, node._id, sectionId)) { + Notification.error(i18n('Cannot add this prerequisite: it would create a circular dependency')); + return; + } + const newRequireNids = checked + ? [...node.requireNids, sectionId] + : node.requireNids.filter((id) => id !== sectionId); + onUpdate(node._id, { requireNids: newRequireNids }); + }, [allSections, node._id, node.requireNids, onUpdate]); + + const handleToggleCollapse = React.useCallback(() => setIsCollapsed((c) => !c), []); + const handleStartEditTitle = React.useCallback(() => setIsEditingTitle(true), []); + const handleDeleteClick = React.useCallback(() => onDelete(node._id), [onDelete, node._id]); + + // Memoize section index lookup for prereqs + const sectionIndexMap = React.useMemo(() => { + const map = new Map(); + allSections.forEach((s, i) => map.set(s._id, i)); + return map; + }, [allSections]); + + const availablePrereqs = React.useMemo( + () => allSections.filter((s) => s._id !== node._id), + [allSections, node._id], + ); + + return ( +
+
+
+
+ + +
+ + {i18n('Section')} {index + 1} + + {isEditingTitle ? ( + setTitleValue(e.target.value)} + onBlur={handleTitleSave} + onKeyDown={handleTitleKeyDown} + autoFocus + style={{ width: '300px' }} + /> + ) : ( + + {node.title || i18n('Untitled Section')} + + + )} +
+
+ + +
+
+ + {!isCollapsed && ( +
+ {availablePrereqs.length > 0 && ( +
+ + {(() => { + const selectedPrereqs = availablePrereqs.filter((s) => node.requireNids.includes(s._id)); + const needsCollapse = availablePrereqs.length > PREREQ_COLLAPSE_THRESHOLD; + const prereqsToShow = needsCollapse && !prereqExpanded ? selectedPrereqs : availablePrereqs; + return ( + <> +
+ {prereqsToShow.map((s) => { + const isChecked = node.requireNids.includes(s._id); + const sectionNum = (sectionIndexMap.get(s._id) ?? 0) + 1; + return ( + + ); + })} +
+ {needsCollapse && ( + + )} + + ); + })()} +
+ )} +
+ +
+
+ )} +
+ ); +} + +export default React.memo(SectionItem); diff --git a/packages/ui-default/components/training/TrainingEditor.tsx b/packages/ui-default/components/training/TrainingEditor.tsx new file mode 100644 index 0000000000..e853e59bac --- /dev/null +++ b/packages/ui-default/components/training/TrainingEditor.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { confirm } from 'vj/components/dialog/index'; +import Notification from 'vj/components/notification'; +import { i18n } from 'vj/utils'; +import SectionItem from './SectionItem'; +import { TrainingFormData, TrainingNode } from './types'; + +const LARGE_TRAINING_THRESHOLD = 20; + +interface TrainingEditorProps { + initialData: TrainingFormData; + isEdit: boolean; + onSubmit: (data: TrainingFormData) => void; + onDelete?: () => void; + canDelete?: boolean; +} + +export default function TrainingEditor({ initialData, isEdit, onSubmit, onDelete, canDelete }: TrainingEditorProps) { + const [formData, setFormData] = React.useState(initialData); + const [loading, setLoading] = React.useState(false); + const [showAdvanced, setShowAdvanced] = React.useState(false); + const [advancedJson, setAdvancedJson] = React.useState(''); + + const descriptionContainerRef = React.useRef(null); + const advancedContainerRef = React.useRef(null); + const descriptionRef = React.useRef(null); + const advancedEditorRef = React.useRef(null); + const editorInitialized = React.useRef(false); + const advancedEditorInitialized = React.useRef(false); + + const isLargeTraining = initialData.dag.length >= LARGE_TRAINING_THRESHOLD; + + React.useEffect(() => { + if (descriptionContainerRef.current && !editorInitialized.current) { + editorInitialized.current = true; + const $ = (window as any).$; + setTimeout(() => $(descriptionContainerRef.current).trigger('vjContentNew'), 0); + } + }, []); + + React.useEffect(() => { + if (showAdvanced && advancedContainerRef.current && !advancedEditorInitialized.current) { + advancedEditorInitialized.current = true; + const $ = (window as any).$; + setTimeout(() => $(advancedContainerRef.current).trigger('vjContentNew'), 0); + } + }, [showAdvanced]); + + React.useEffect(() => { + if (showAdvanced) setAdvancedJson(JSON.stringify(formData.dag, null, 2)); + }, [showAdvanced, formData.dag]); + + const updateFormField = React.useCallback((field: K, value: TrainingFormData[K]) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }, []); + + const addSection = React.useCallback(() => { + setFormData((prev) => { + const newId = prev.dag.length > 0 ? Math.max(...prev.dag.map((n) => n._id)) + 1 : 1; + return { ...prev, dag: [...prev.dag, { _id: newId, title: `${i18n('Section')} ${newId}`, requireNids: [], pids: [] }] }; + }); + }, []); + + const updateSection = React.useCallback((nodeId: number, updates: Partial) => { + setFormData((prev) => ({ + ...prev, + dag: prev.dag.map((node) => (node._id === nodeId ? { ...node, ...updates } : node)), + })); + }, []); + + const deleteSection = React.useCallback(async (nodeId: number) => { + if (!await confirm(i18n('Are you sure you want to delete this section?'))) return; + setFormData((prev) => ({ ...prev, dag: prev.dag.filter((node) => node._id !== nodeId) })); + }, []); + + const moveSection = React.useCallback((fromIndex: number, toIndex: number) => { + setFormData((prev) => { + if (toIndex < 0 || toIndex >= prev.dag.length) return prev; + const newDag = [...prev.dag]; + const [removed] = newDag.splice(fromIndex, 1); + newDag.splice(toIndex, 0, removed); + return { ...prev, dag: newDag }; + }); + }, []); + + const handleApplyJson = React.useCallback(() => { + const jsonValue = advancedEditorRef.current?.value; + if (!jsonValue) return; + try { + const parsed = JSON.parse(jsonValue); + if (!Array.isArray(parsed)) { + Notification.error(i18n('Invalid JSON format: must be an array')); + return; + } + setFormData((prev) => ({ ...prev, dag: parsed })); + Notification.success(i18n('JSON applied to editor')); + } catch { + Notification.error(i18n('Invalid JSON format')); + } + }, []); + + const handleSubmit = React.useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + const currentDescription = descriptionRef.current?.value ?? formData.description; + const submitData = { ...formData, description: currentDescription }; + if (submitData.dag.length === 0) { + Notification.error(i18n('Please add at least one section')); + return; + } + if (submitData.dag.find((node) => node.pids.length === 0)) { + Notification.error(i18n('Each section must have at least one problem')); + return; + } + setLoading(true); + try { + await onSubmit(submitData); + } finally { + setLoading(false); + } + }, [formData, onSubmit]); + + const handleDelete = React.useCallback(async () => { + if (!onDelete) return; + if (!await confirm(i18n('Confirm deleting this training? Its files and status will be deleted as well.'))) return; + onDelete(); + }, [onDelete]); + + const toggleAdvanced = React.useCallback(() => setShowAdvanced((s) => !s), []); + + return ( +
+

{i18n('Basic Information')}

+
+
+
+ +
+
+ +
+
+
+