From 3697e627c75f985ba70fb9a46404ef02ad4b110d Mon Sep 17 00:00:00 2001 From: Hugo Smith Date: Fri, 16 May 2025 14:43:18 +0800 Subject: [PATCH 1/2] Clean up create.js with chatgpt --- src/app/static/js/create.js | 1124 ++++++++++++++++------------------- 1 file changed, 519 insertions(+), 605 deletions(-) diff --git a/src/app/static/js/create.js b/src/app/static/js/create.js index 2b3346f..695095a 100644 --- a/src/app/static/js/create.js +++ b/src/app/static/js/create.js @@ -1,3 +1,71 @@ +const CONFIG = { + selectors: { + searchInput: '#searchInput', + saveButton: '#saveButton', + unitList: '#unitList', + planName: '#planName', + prefillSelect: '#prefillSelect', + csCoreSelectWrapper: '#csCoreSelectWrapper', + csCoreSelect: '#csCoreSelect' + }, + endpoints: { + recommendedUnits: '/units/recommended', + getPlan: '/plans/get', + savePlan: '/plans/save' + }, + templates: { + prefillTemplates: { + cs: [ + { unit_code: 'CITS1401', row: 1, col: 1 }, + { unit_code: 'CITS1402', row: 1, col: 2 }, + { unit_code: 'CITS1003', row: 2, col: 1 }, + { unit_code: 'CITS2005', row: 3, col: 1 }, + { unit_code: 'CITS2200', row: 3, col: 2 }, + { unit_code: 'CITS2002', row: 4, col: 1 }, + { unit_code: 'CITS2211', row: 4, col: 2 }, + { unit_code: 'CITS3403', row: 5, col: 1 }, + { unit_code: 'CITS3002', row: 5, col: 2 }, + { unit_code: 'CITS3200', row: 6, col: 1 }, + { unit_code: 'CITS3001', row: 6, col: 2 } + ], + cyber: [ + { unit_code: 'CITS1401', row: 1, col: 1 }, + { unit_code: 'PHIL1001', row: 1, col: 2 }, + { unit_code: 'CITS1003', row: 2, col: 1 }, + { unit_code: 'CITS2006', row: 3, col: 1 }, + { unit_code: 'CITS2002', row: 4, col: 1 }, + { unit_code: 'CITS3002', row: 5, col: 1 }, + { unit_code: 'CITS3403', row: 5, col: 2 }, + { unit_code: 'CITS3007', row: 5, col: 3 }, + { unit_code: 'CITS3200', row: 6, col: 1 }, + { unit_code: 'CITS3006', row: 6, col: 2 } + ], + data_science: [ + { unit_code: 'CITS1401', row: 1, col: 1 }, + { unit_code: 'PHIL1001', row: 1, col: 2 }, + { unit_code: 'CITS1402', row: 2, col: 1 }, + { unit_code: 'STAT1400', row: 2, col: 2 }, + { unit_code: 'STAT2401', row: 3, col: 1 }, + { unit_code: 'STAT2402', row: 4, col: 1 }, + { unit_code: 'CITS2402', row: 4, col: 2 }, + { unit_code: 'CITS3403', row: 5, col: 1 }, + { unit_code: 'CITS3401', row: 5, col: 2 }, + { unit_code: 'CITS3200', row: 6, col: 1 }, + { unit_code: 'STAT3064', row: 6, col: 2 }, + { unit_code: 'STAT3405', row: 6, col: 3 } + ] + }, + csFinalCoreOptions: [ + { code: "CITS3003", name: "Graphics and Animation", semester: 1 }, + { code: "CITS3007", name: "Secure Coding", semester: 1 }, + { code: "CITS3009", name: "WIL Internship", semester: 1 }, + { code: "CITS3005", name: "Knowledge Representation", semester: 2 }, + { code: "CITS3011", name: "Intelligent Agents", semester: 2 }, + { code: "CITS3402", name: "High Performance Computing", semester: 2 } + ] + } +}; + class UnitModel { constructor({ unit_name, unit_code, semester1, semester2, exam, prerequisites }) { this.unit_name = unit_name; @@ -9,654 +77,500 @@ class UnitModel { } } -document.addEventListener('DOMContentLoaded', () => { - // ───── Constants & State ───── - const searchInput = document.getElementById('searchInput'); - const saveButton = document.getElementById('saveButton'); - const unitList = document.getElementById('unitList'); - const planName = document.getElementById('planName'); - const cellSelector = '.unit-cell'; - const dropZones = Array.from(document.querySelectorAll(cellSelector)); - const csrfToken = document.querySelector('meta[name="csrf-token"]').content; - let allUnits = []; - let availableUnits = []; - let placedUnits = {}; // cell key is unit_code - const prefillTemplates = { - cs: [ - { unit_code: 'CITS1401', row: 1, col: 1 }, - { unit_code: 'CITS1402', row: 1, col: 2 }, - { unit_code: 'CITS1003', row: 2, col: 1 }, - { unit_code: 'CITS2005', row: 3, col: 1 }, - { unit_code: 'CITS2200', row: 3, col: 2 }, - { unit_code: 'CITS2002', row: 4, col: 1 }, - { unit_code: 'CITS2211', row: 4, col: 2 }, - { unit_code: 'CITS3403', row: 5, col: 1 }, - { unit_code: 'CITS3002', row: 5, col: 2 }, - { unit_code: 'CITS3200', row: 6, col: 1 }, - { unit_code: 'CITS3001', row: 6, col: 2 } - ], - cyber: [ - { unit_code: 'CITS1401', row: 1, col: 1 }, - { unit_code: 'PHIL1001', row: 1, col: 2 }, - { unit_code: 'CITS1003', row: 2, col: 1 }, - { unit_code: 'CITS2006', row: 3, col: 1 }, - { unit_code: 'CITS2002', row: 4, col: 1 }, - { unit_code: 'CITS3002', row: 5, col: 1 }, - { unit_code: 'CITS3403', row: 5, col: 2 }, - { unit_code: 'CITS3007', row: 5, col: 3 }, - { unit_code: 'CITS3200', row: 6, col: 1 }, - { unit_code: 'CITS3006', row: 6, col: 2 }, - ], - data_science: [ - { unit_code: 'CITS1401', row: 1, col: 1 }, - { unit_code: 'PHIL1001', row: 1, col: 2 }, - { unit_code: 'CITS1402', row: 2, col: 1 }, - { unit_code: 'STAT1400', row: 2, col: 2 }, - { unit_code: 'STAT2401', row: 3, col: 1 }, - { unit_code: 'STAT2402', row: 4, col: 1 }, - { unit_code: 'CITS2402', row: 4, col: 2 }, - { unit_code: 'CITS3403', row: 5, col: 1 }, - { unit_code: 'CITS3401', row: 5, col: 2 }, - { unit_code: 'CITS3200', row: 6, col: 1 }, - { unit_code: 'STAT3064', row: 6, col: 2 }, - { unit_code: 'STAT3405', row: 6, col: 3 }, - ] - } - - const csFinalCoreOptions = [ - { code: "CITS3003", name: "Graphics and Animation", semester: 1 }, - { code: "CITS3007", name: "Secure Coding", semester: 1 }, - { code: "CITS3009", name: "WIL Internship", semester: 1 }, - { code: "CITS3005", name: "Knowledge Representation", semester: 2 }, - { code: "CITS3011", name: "Intelligent Agents", semester: 2 }, - { code: "CITS3402", name: "High Performance Computing", semester: 2 } - ]; - - // ───── Initialization ───── - function init() { - applyInitialPlan(); - loadUnits(); - setupPrefillHandler(); - setupSearchFilter(); - setupSaveHandler(); - setupDragAndDrop(); - } - - // ───── Initial render of grid ───── - async function applyInitialPlan() { - // 1) fetch existing plan - let init = { name: '', units: [] }; - const planId = new URLSearchParams(window.location.search).get('id'); - if (planId) { - try { - const resp = await fetch(`/plans/get?id=${planId}`, { credentials: 'include' }); - const body = await resp.json(); - if (resp.ok && body.ok) init = body.plan; - else console.warn('Could not load plan:', body.message); - } catch (err) { - console.error('Error fetching plan:', err); - } - } +// — constants — +const { selectors, endpoints, templates } = CONFIG; + +const DOM = Object.fromEntries( + Object.entries(selectors).map(([key, sel]) => [key, document.querySelector(sel)]) +); + +const CELL_SELECTOR = '.unit-cell'; +const DROP_ZONES = Array.from(document.querySelectorAll(CELL_SELECTOR)); +const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]').content; + +// — state — +let allUnits = []; +let availableUnits = []; +let placedUnits = {}; // { 'row,col': 'CITS1401', ... } + +// — init — +(function init() { + applyInitialPlan(); + loadUnits(); + setupPrefillHandler(); + setupSearchFilter(); + setupSaveHandler(); + setupDragAndDrop(); +})(); + +// — helpers — +function byCode(code) { + return allUnits.find(u => u.unit_code === code); +} - // 2) clear everything & set the plan‐name - clearGrid(); - planName.value = init.name || ''; - placedUnits = {}; - - // 3) normalise into an array if it was an object - const unitsArray = Array.isArray(init.units) - ? init.units - : Object.values(init.units); - - // 4) for each saved entry in the users saved plan, place the corrsponding unit into the grid - unitsArray.forEach(u => { - const { row, col } = u; - const key = `${row},${col}`; - const cell = document.querySelector(`.unit-cell[data-key="${key}"]`); - - // Build the unitmodel payload - const dataForModel = { - unit_name: u.unitname, - unit_code: u.unitcode, - semester1: u.semester1, - semester2: u.semester2, - exam: u.exam - }; +function createUnitDiv(unit) { + const div = document.createElement('div'); + div.className = [ + 'unit', 'flex', 'flex-col', 'justify-center', 'items-stretch', + 'w-full', 'h-full', 'p-2', 'rounded-lg', 'border', + 'border-gray-300', 'dark:border-gray-700', 'cursor-move', 'gap-1', + 'bg-white', 'dark:bg-dark-fg', 'shadow-sm' + ].join(' '); + div.dataset.code = unit.unit_code; + div.style.minWidth = '0'; + + const name = document.createElement('span'); + name.className = 'flex-grow font-bold text-sm leading-tight w-full'; + name.textContent = unit.unit_name; + name.style.minWidth = '0'; + name.style.wordBreak = 'break-word'; + div.appendChild(name); + + const tags = document.createElement('div'); + tags.className = 'flex flex-wrap gap-1 items-center'; + if (unit.semester1) tags.appendChild(tag('Sem 1', 'bg-blue-100 text-blue-800')); + if (unit.semester2) tags.appendChild(tag('Sem 2', 'bg-green-100 text-green-800')); + if (unit.exam) tags.appendChild(tag('Exam', 'bg-red-100 text-red-700')); + tags.appendChild(tag(unit.unit_code, 'bg-slate-200 text-slate-700')); + div.appendChild(tags); + + div.draggable = true; + div.addEventListener('dragstart', dragStartHandler); + div.addEventListener('dragend', dragEndHandler); + return div; +} - const uintm = new UnitModel(dataForModel); - const div = createUnitDiv(uintm); +function tag(text, cls) { + const span = document.createElement('span'); + span.className = `tag px-1 py-[1.5px] text-[10px] rounded font-semibold ${cls}`; + span.textContent = text; + return span; +} - cell.innerHTML = ''; - cell.appendChild(div); - placedUnits[key] = uintm.unit_code; - }); +function placeholderHtml() { + return '
Drop unit here
'; +} - // 5) rebuild sidebar and validate - resetAvailableUnits(); - renderUnitList(availableUnits); - validateAllCells(); - } +function resetAvailableUnits() { + const placedCodes = Object.values(placedUnits); + availableUnits = allUnits.filter(u => !placedCodes.includes(u.unit_code)); +} - // ───── Prefill template logic ───── - function setupPrefillHandler() { - const select = document.getElementById('prefillSelect'); - const coreSelectWrapper = document.getElementById('csCoreSelectWrapper'); - const coreSelect = document.getElementById('csCoreSelect'); - - if (!select) return; - - // When you pick a major… - select.addEventListener('change', () => { - const key = select.value; - clearGrid(); - - if (key === 'cs') { - // 1) apply all the *fixed* CS units (minus the final core) - applyTemplate(prefillTemplates['cs']); - - // 2) show + populate the dropdown - coreSelectWrapper.classList.remove('hidden'); - coreSelect.innerHTML = ''; - csFinalCoreOptions.forEach(u => { - const o = document.createElement('option'); - o.value = u.code; - o.textContent = `${u.code} – ${u.name} (Sem ${u.semester})`; - coreSelect.appendChild(o); - }); - } else { - applyTemplate(prefillTemplates[key] || []); - coreSelectWrapper.classList.add('hidden'); - coreSelect.innerHTML = ''; - } - }); +// — prerequisite utility — +function checkPrerequisites(unit, placedWithTime) { + if (!unit?.prerequisites) return { ok: true }; - // When you choose your final core… - coreSelect.addEventListener('change', () => { - const code = coreSelect.value; - const u = csFinalCoreOptions.find(x => x.code === code); - if (!u) return; - - const row = u.semester === 1 ? 5 : 6; - const col = 3; - const key = `${row},${col}`; - const cell = document.querySelector(`.unit-cell[data-key="${key}"]`); - if (!cell) return; - - // clear + drop in the chosen unit - cell.innerHTML = ''; - const unitModel = allUnits.find(x => x.unit_code === code); - if (unitModel) { - cell.appendChild(createUnitDiv(unitModel)); - placedUnits[key] = code; - } - - // refresh sidebar & validation - resetAvailableUnits(); - renderUnitList(availableUnits); - validateAllCells(); - }); - } + const currentTime = placedWithTime[unit.unit_code]; + if (currentTime === undefined) return { ok: true }; + const groups = unit.prerequisites + .split('|') + .map(g => g.trim().split('+').map(c => c.trim())); - function applyTemplate(template) { - clearGrid(); - template.forEach(({ unit_code, row, col }) => { - const unit = allUnits.find(u => u.unit_code === unit_code); - if (!unit) return console.warn(`Unit ${unit_code} not found`); + const ok = groups.some(group => + group.every(pr => placedWithTime[pr] !== undefined && placedWithTime[pr] < currentTime) + ); - // Locate cell by data-key! - const cell = document.querySelector(`.unit-cell[data-key="${row},${col}"]`); - if (!cell) return; + const tooltip = groups.map(g => g.join(' AND ')).join(' OR '); + return { ok, tooltip, groups }; +} - const div = createUnitDiv(unit); - cell.innerHTML = ""; - cell.appendChild(div); +// — data loading — +function loadUnits() { + fetch(endpoints.recommendedUnits) + .then(res => res.json()) + .then(data => { + allUnits = data.map(u => new UnitModel(u)); + resetAvailableUnits(); + renderUnitList(availableUnits); + validateAllCells(); + }) + .catch(() => console.error('Failed to load units')); +} - // Remove from available - availableUnits = availableUnits.filter(u => u.unit_code !== unit_code); - }); - renderUnitList(availableUnits); +// — grid/plan initialisation — +async function applyInitialPlan() { + let init = { name: '', units: [] }; + const planId = new URLSearchParams(location.search).get('id'); + if (planId) { + try { + const res = await fetch(`${endpoints.getPlan}?id=${planId}`, { credentials: 'include' }); + const body = await res.json(); + if (res.ok && body.ok) init = body.plan; + else console.warn('Could not load plan:', body.message); + } catch (e) { console.error('Error fetching plan:', e); } } - function clearGrid() { - dropZones.forEach(zone => { - // Remove any .unit divs - const oldUnits = Array.from(zone.querySelectorAll('.unit')); - oldUnits.forEach(div => div.remove()); + clearGrid(); + DOM.planName.value = init.name || ''; + placedUnits = {}; + + (Array.isArray(init.units) ? init.units : Object.values(init.units)).forEach(u => { + const key = `${u.row},${u.col}`; + const cell = document.querySelector(`${CELL_SELECTOR}[data-key="${key}"]`); + if (!cell) return; + + const unitModel = new UnitModel({ + unit_name: u.unitname, + unit_code: u.unitcode, + semester1: u.semester1, + semester2: u.semester2, + exam: u.exam, + prerequisites: u.prerequisites }); - availableUnits = [...allUnits]; + cell.innerHTML = ''; + cell.appendChild(createUnitDiv(unitModel)); + placedUnits[key] = unitModel.unit_code; + }); + + resetAvailableUnits(); + renderUnitList(availableUnits); + validateAllCells(); +} + +function clearGrid() { + DROP_ZONES.forEach(z => z.innerHTML = placeholderHtml()); + availableUnits = [...allUnits]; + renderUnitList(availableUnits); +} + +// — UI rendering — +function renderUnitList(units) { + DOM.unitList.innerHTML = ''; + units.forEach(u => DOM.unitList.appendChild(createUnitDiv(u))); + setDragHandlers(DOM.unitList); +} + +// — prefill templates — +function setupPrefillHandler() { + const select = DOM.prefillSelect; + const coreSelectWrapper = DOM.csCoreSelectWrapper; + const coreSelect = DOM.csCoreSelect; + if (!select) return; + + select.addEventListener('change', () => { + const key = select.value; + clearGrid(); + + if (key === 'cs') { + applyTemplate(templates.prefillTemplates.cs); + coreSelectWrapper.classList.remove('hidden'); + coreSelect.innerHTML = ''; + templates.csFinalCoreOptions.forEach(u => { + const o = document.createElement('option'); + o.value = u.code; + o.textContent = `${u.code} – ${u.name} (Sem ${u.semester})`; + coreSelect.appendChild(o); + }); + } else { + applyTemplate(templates.prefillTemplates[key] || []); + coreSelectWrapper.classList.add('hidden'); + coreSelect.innerHTML = ''; + } + }); + + coreSelect.addEventListener('change', () => { + const code = coreSelect.value; + const u = templates.csFinalCoreOptions.find(x => x.code === code); + if (!u) return; + + const row = u.semester === 1 ? 5 : 6; + const col = 3; + const key = `${row},${col}`; + const cell = document.querySelector(`${CELL_SELECTOR}[data-key="${key}"]`); + if (!cell) return; + + cell.innerHTML = ''; + const unit = byCode(code); + if (unit) { + cell.appendChild(createUnitDiv(unit)); + placedUnits[key] = code; + } + + resetAvailableUnits(); renderUnitList(availableUnits); - } + validateAllCells(); + }); +} - // ───── Fetch all units for the list ───── - function loadUnits() { - fetch('/units/recommended') - .then(res => res.json()) - .then(data => { - allUnits = data.map(u => new UnitModel(u)); - resetAvailableUnits(); - renderUnitList(availableUnits); - validateAllCells(); - }) - .catch(() => console.error('Failed to load units')); - } - function resetAvailableUnits() { - const placedCodes = Object.values(placedUnits); - availableUnits = allUnits.filter(u => !placedCodes.includes(u.unit_code)); - } +function applyTemplate(template) { + clearGrid(); + template.forEach(({ unit_code, row, col }) => { + const unit = byCode(unit_code); + if (!unit) return; + const cell = document.querySelector(`${CELL_SELECTOR}[data-key="${row},${col}"]`); + if (!cell) return; + cell.innerHTML = ''; + cell.appendChild(createUnitDiv(unit)); + availableUnits = availableUnits.filter(u => u.unit_code !== unit_code); + }); + renderUnitList(availableUnits); +} - // ───── DOM rendering ───── - function renderUnitList(units) { - unitList.innerHTML = ''; - units.forEach(u => { - let div = createUnitDiv(u); - div.setAttribute('data-code', u.unit_code); - unitList.appendChild(div); - }); - setDragHandlers(unitList); - } - function createUnitDiv(unit) { - const div = document.createElement('div'); - div.className = [ //changed these classes to fill the parent container (cell) - 'unit', - 'flex', 'flex-col', - 'justify-center', 'items-stretch', - 'w-full', 'h-full', - 'p-2', 'rounded-lg', 'border', - 'border-gray-300', 'dark:border-gray-700', 'cursor-move', 'gap-1', - 'bg-white', 'dark:bg-dark-fg', 'shadow-sm' - ].join(' '); - div.setAttribute('data-code', unit.unit_code); - div.style.minWidth = "0"; - - // Name - const nameSpan = document.createElement('span'); - nameSpan.className = 'flex-grow font-bold text-sm leading-tight w-full'; - nameSpan.textContent = unit.unit_name; - nameSpan.style.minWidth = "0"; - nameSpan.style.wordBreak = "break-word"; - div.appendChild(nameSpan); - - // tags, I think this looks nice than just listing them - const tags = document.createElement('div'); - tags.className = 'flex flex-wrap gap-1 items-center mt-0'; - if (unit.semester1) tags.appendChild(tag('Sem 1', 'bg-blue-100 text-blue-800')); - if (unit.semester2) tags.appendChild(tag('Sem 2', 'bg-green-100 text-green-800')); - if (unit.exam) tags.appendChild(tag('Exam', 'bg-red-100 text-red-700')); - tags.appendChild(tag(unit.unit_code, 'bg-slate-200 text-slate-700')); - div.appendChild(tags); - - // drag attributes - div.setAttribute('draggable', 'true'); +// — drag & drop — +function dragStartHandler(e) { + e.dataTransfer.setData('text/plain', this.dataset.code); + e.dataTransfer.effectAllowed = 'move'; + setTimeout(() => this.classList.add('opacity-50')); +} +function dragEndHandler() { + this.classList.remove('opacity-50'); +} +function setDragHandlers(ctx = document) { + ctx.querySelectorAll('.unit').forEach(div => { + div.draggable = true; + div.removeEventListener('dragstart', dragStartHandler); + div.removeEventListener('dragend', dragEndHandler); div.addEventListener('dragstart', dragStartHandler); - div.addEventListener('dragend', dragEndHandler); - - return div; - } - function tag(text, classes) { - const span = document.createElement('span'); - span.className = `tag px-1 py-[1.5px] text-[10px] rounded font-semibold inline-block ${classes}`; - span.textContent = text; - return span; - } - function getPlaceholderHtml() { - return '
Drop unit here
'; - } + div.addEventListener('dragend', dragEndHandler); + }); +} +function setupDragAndDrop() { + // list -> return to list + DOM.unitList.addEventListener('dragover', e => e.preventDefault()); + DOM.unitList.addEventListener('drop', e => { + e.preventDefault(); + const code = e.dataTransfer.getData('text/plain'); + if (!code) return; + + const gridUnit = document.querySelector(`${CELL_SELECTOR} .unit[data-code="${code}"]`); + if (gridUnit) { + const cell = gridUnit.closest(CELL_SELECTOR); + gridUnit.remove(); + cell.innerHTML = placeholderHtml(); + delete placedUnits[cell.dataset.key]; + } + if (!DOM.unitList.querySelector(`.unit[data-code="${code}"]`)) { + const unit = byCode(code); + if (unit) DOM.unitList.appendChild(createUnitDiv(unit)); + } + resetAvailableUnits(); + validateAllCells(); + }); - // ───── Drag/drop support ───── - function setupDragAndDrop() { - // allow drop to return unit to list - unitList.addEventListener('dragover', e => e.preventDefault()); - unitList.addEventListener('drop', e => { + // grid cells + DROP_ZONES.forEach(cell => { + cell.addEventListener('dragover', e => e.preventDefault()); + cell.addEventListener('drop', e => { e.preventDefault(); const code = e.dataTransfer.getData('text/plain'); if (!code) return; - let gridUnit = document.querySelector(`.unit-cell .unit[data-code="${code}"]`); - if (gridUnit) { - let cell = gridUnit.closest('.unit-cell'); - gridUnit.remove(); - cell.innerHTML = getPlaceholderHtml(); - delete placedUnits[cell.dataset.key]; - } - if (!unitList.querySelector(`.unit[data-code="${code}"]`)) { - const unit = allUnits.find(u => u.unit_code === code); - if (unit) { - const div = createUnitDiv(unit); - unitList.appendChild(div); - } + + const existing = document.querySelector(`${CELL_SELECTOR} .unit[data-code="${code}"]`); + if (existing) { + const oldCell = existing.closest(CELL_SELECTOR); + existing.remove(); + oldCell.innerHTML = placeholderHtml(); + delete placedUnits[oldCell.dataset.key]; } + + const fromList = DOM.unitList.querySelector(`.unit[data-code="${code}"]`); + if (fromList) fromList.remove(); + + cell.innerHTML = ''; + const unit = byCode(code); + if (!unit) return; + cell.appendChild(createUnitDiv(unit)); + placedUnits[cell.dataset.key] = code; + resetAvailableUnits(); + renderUnitList(availableUnits); validateAllCells(); }); + }); +} - // Grid cells - dropZones.forEach(cell => { - cell.addEventListener('dragover', e => e.preventDefault()); - cell.addEventListener('drop', function(e) { - e.preventDefault(); - const code = e.dataTransfer.getData('text/plain'); - if (!code) return; - - // Remove duplicates - let existing = document.querySelector(`.unit-cell .unit[data-code="${code}"]`); - if (existing) { - let oldCell = existing.closest('.unit-cell'); - existing.remove(); - oldCell.innerHTML = getPlaceholderHtml(); - delete placedUnits[oldCell.dataset.key]; - } +// — validation — +function validateAllCells() { + DROP_ZONES.forEach(cell => { + const unitDiv = cell.querySelector('.unit'); + if (!unitDiv) { + cell.classList.remove('ring-2', 'ring-red-400'); + cell.querySelector('.semester-warning')?.remove(); + return; + } + const unit = byCode(unitDiv.dataset.code); + if (!unit) return; + validateSemesterPlacement(cell, unit); + }); + validatePrereqs(); +} - let fromList = unitList.querySelector(`.unit[data-code="${code}"]`); - if (fromList) fromList.remove(); +function validateSemesterPlacement(cell, unit) { + const [row] = cell.dataset.key.split(',').map(Number); + const targetSemester = (row % 2 === 1) ? 1 : 2; + const ok = (targetSemester === 1 && unit.semester1) || (targetSemester === 2 && unit.semester2); + + cell.classList.remove('ring-2', 'ring-red-400'); + cell.querySelector('.semester-warning')?.remove(); + + if (!ok) { + cell.classList.add('ring-2', 'ring-red-400'); + const badge = document.createElement('div'); + badge.className = 'semester-warning absolute top-1 right-1 bg-red-500 text-white text-[10px] px-1 rounded shadow'; + badge.textContent = 'Wrong Semester'; + badge.style.zIndex = 20; + cell.querySelector('.unit').style.position = 'relative'; + cell.querySelector('.unit').appendChild(badge); + } +} - cell.innerHTML = ''; - const unit = allUnits.find(u => u.unit_code === code); - if (!unit) return; - const div = createUnitDiv(unit); - cell.appendChild(div); - placedUnits[cell.dataset.key] = code; +function validatePrereqs() { + const placedWithTime = {}; + DROP_ZONES.forEach(cell => { + const div = cell.querySelector('.unit'); + if (!div) return; + const [row, col] = cell.dataset.key.split(',').map(Number); + placedWithTime[div.dataset.code] = (row - 1) * 4 + col; + }); - resetAvailableUnits(); - renderUnitList(availableUnits); // updates the sidebar - validateAllCells(); // updates all warnings - }); - }); - } - function setDragHandlers(ctx) { - (ctx || document).querySelectorAll('.unit').forEach(div => { - div.setAttribute('draggable', 'true'); - div.removeEventListener('dragstart', dragStartHandler); - div.removeEventListener('dragend', dragEndHandler); - div.addEventListener('dragstart', dragStartHandler); - div.addEventListener('dragend', dragEndHandler); - }); - } - function dragStartHandler(e) { - const code = this.getAttribute('data-code'); - e.dataTransfer.setData('text/plain', code); - e.dataTransfer.effectAllowed = 'move'; - setTimeout(() => this.classList.add('opacity-50'), 0); - } - function dragEndHandler() { - this.classList.remove('opacity-50'); - } + DROP_ZONES.forEach(cell => { + const div = cell.querySelector('.unit'); + if (!div) return; + const unit = byCode(div.dataset.code); + const { ok, tooltip } = checkPrerequisites(unit, placedWithTime); - // ───── Semester-check support ───── - function validateAllCells() { - dropZones.forEach(cell => { - const unitDiv = cell.querySelector('.unit'); - if (unitDiv) { - const code = unitDiv.getAttribute('data-code'); - const unit = allUnits.find(u => u.unit_code === code); - if (unit) { - validateSemesterPlacement(cell, unit); - validatePrereqs(); - } - } else { - cell.classList.remove("ring-2", "ring-red-400"); - const prevBadge = cell.querySelector('.semester-warning'); - if (prevBadge) prevBadge.remove(); - } - }); - } - function validateSemesterPlacement(cell, unit) { - const key = cell.dataset.key.split(','); - const semIdx = parseInt(key[0]); - // in the jinja template we can know for sure that a key of 1 is semester 1 and a key of 2 is semsester 2 - const targetSemester = (semIdx % 2 === 1) ? 1 : 2; - const ok = (targetSemester === 1 && unit.semester1) || - (targetSemester === 2 && unit.semester2); - - cell.classList.remove("ring-2", "ring-red-400"); - const prevBadge = cell.querySelector('.semester-warning'); - if (prevBadge) prevBadge.remove(); + cell.classList.remove('ring-2', 'ring-red-400'); + cell.querySelector('.prereq-warning')?.remove(); if (!ok) { - cell.classList.add("ring-2", "ring-red-400"); - const unitDiv = cell.querySelector('.unit'); - if (unitDiv) { - unitDiv.style.position = "relative"; - const badge = document.createElement('div'); - badge.className = "semester-warning absolute top-1 right-1 bg-red-500 text-white text-[10px] px-1 py-0.5 rounded shadow"; - badge.innerText = "Wrong Semester"; - badge.style.zIndex = 20; - unitDiv.appendChild(badge); - } + cell.classList.add('ring-2', 'ring-red-400'); + const badge = document.createElement('div'); + badge.className = 'prereq-warning absolute top-1 left-1 text-[10px] bg-red-500 text-white px-1 rounded'; + badge.textContent = 'PREREQ!'; + badge.style.zIndex = 20; + div.style.position = 'relative'; + div.title = `Requires: ${tooltip}`; + div.appendChild(badge); } - } + }); +} - function validatePrereqs() { - const placedWithTime = {}; - dropZones.forEach(cell => { - const div = cell.querySelector('.unit'); - if (!div) return; - const [r, c] = cell.dataset.key.split(',').map(Number); - placedWithTime[div.dataset.code] = (r - 1) * 4 + c; - }); +// — search — +function setupSearchFilter() { + DOM.searchInput.addEventListener('input', () => { + const q = DOM.searchInput.value.trim().toLowerCase(); + const filtered = q ? availableUnits.filter(u => + u.unit_name.toLowerCase().includes(q) || u.unit_code.toLowerCase().includes(q) + ) : availableUnits; + renderUnitList(filtered); + }); +} - dropZones.forEach(cell => { - cell.classList.remove('ring-2', 'ring-red-400'); - const old = cell.querySelector('.prereq-warning'); - if (old) old.remove(); - }); +// — save — +function setupSaveHandler() { + DOM.saveButton.addEventListener('click', () => { + const name = DOM.planName.value.trim(); + if (!name) return createAlert('Please enter a plan name.', 'error'); - dropZones.forEach(cell => { + const major = DOM.prefillSelect.value; + if (major === 'cs' && !DOM.csCoreSelect.value) + return createAlert('Please select your final core unit before saving.', 'error'); + + const units = []; + DROP_ZONES.forEach(cell => { const div = cell.querySelector('.unit'); if (!div) return; - const code = div.dataset.code; - const unit = allUnits.find(u => u.unit_code === code); - if (!unit || !unit.prerequisites) return; - - const currentTime = placedWithTime[code]; - const groups = unit.prerequisites - .split('|') - .map(g => g.trim().split('+').map(c => c.trim())); - - const ok = groups.some(group => - group.every(pr => placedWithTime[pr] !== undefined && placedWithTime[pr] < currentTime) - ); - if (!ok) { - cell.classList.add('ring-2', 'ring-red-400'); - const badge = document.createElement('div'); - badge.className = 'prereq-warning absolute top-1 left-1 text-[10px] bg-red-500 text-white px-1 rounded'; - badge.textContent = 'PREREQ!'; - badge.style.zIndex = 20; - div.style.position = 'relative'; - div.appendChild(badge); - - // create tooltip - const tooltipText = groups - .map(group => group.join(' AND ')) - .join(' OR '); - - div.setAttribute('title', `Requires: ${tooltipText}`); - } + units.push({ + unit_name: byCode(div.dataset.code).unit_name, + unit_code: div.dataset.code, + column: +cell.dataset.key.split(',')[1], + row: +cell.dataset.key.split(',')[0] + }); }); - } + if (!units.length) return createAlert('Please place at least one unit.', 'error'); - // ───── Search/filter support ───── - function setupSearchFilter() { - searchInput.addEventListener('input', () => { - const q = searchInput.value.trim().toLowerCase(); - let filtered = availableUnits; - if (q) - filtered = availableUnits.filter(u => - u.unit_name.toLowerCase().includes(q) || u.unit_code.toLowerCase().includes(q) - ); - renderUnitList(filtered); + // prereq check before save + const placedWithTime = {}; + DROP_ZONES.forEach(cell => { + const div = cell.querySelector('.unit'); + if (!div) return; + const [r, c] = cell.dataset.key.split(',').map(Number); + placedWithTime[div.dataset.code] = (r - 1) * 4 + c; }); - } - - // ───── Save logic ───── - function setupSaveHandler() { - saveButton.addEventListener('click', () => { - const name = planName.value.trim(); - if (!name) return createAlert('Please enter a plan name.', 'error'); - - const major = document.getElementById('prefillSelect').value; - - if (major === 'cs' && !document.getElementById('csCoreSelect').value) { - console.log("I can see that this user is doing cs"); - createAlert('Please select your final core unit before saving.', 'error'); - return; - } - const units = []; - for (let cell of dropZones) { - const div = cell.querySelector('.unit'); - if (!div) continue; - const code = div.getAttribute('data-code'); - const unit = allUnits.find(u => u.unit_code === code); - if (unit) { - units.push({ - unit_name: unit.unit_name, - unit_code: unit.unit_code, - column: +cell.dataset.key.split(',')[1], - row: +cell.dataset.key.split(',')[0] - }); - } - } - if (!units.length) return createAlert('Please place at least one unit.', 'error'); - - const placedWithTime = {}; - for (let cell of dropZones) { - const div = cell.querySelector('.unit'); - if (!div) continue; - const key = cell.dataset.key; - const [row, col] = key.split(',').map(Number); - const timeIndex = (row - 1) * 4 + col; - const code = div.getAttribute('data-code'); - placedWithTime[code] = timeIndex; - - } - - - // Check each unit's prerequisites - const unsatisfied = []; - - for (let cell of dropZones) { - const div = cell.querySelector('.unit'); - if (!div) continue; - const code = div.getAttribute('data-code'); - const unit = allUnits.find(u => u.unit_code === code); - if (!unit) { - continue; - } - - if (!unit.prerequisites) { - continue; - } - - const currentTime = placedWithTime[code]; - - const groups = unit.prerequisites - .split('|') - .map(group => group.trim().split('+').map(c => c.trim())); - - - const satisfies = groups.some(group => - group.every(prereq => { - const prereqTime = placedWithTime[prereq]; - const status = prereqTime !== undefined && prereqTime < currentTime; - return status; - }) - ); - - if (!satisfies && groups.length > 0 && groups[0][0] !== "") { - unsatisfied.push({ unit: code, groups }); - } - } - - // Show error if any - if (unsatisfied.length > 0) { - createAlert("Please make sure all units have their prerequisites satisfied.", 'error'); - return; - } - - fetch('/plans/save', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - }, - body: JSON.stringify({ plan_name: name, units, plan_id: edit_plan_id }) + const unsatisfied = units.filter(u => !checkPrerequisites(byCode(u.unit_code), placedWithTime).ok); + if (unsatisfied.length) return createAlert('Please make sure all units have their prerequisites satisfied.', 'error'); + + fetch(endpoints.savePlan, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': CSRF_TOKEN + }, + body: JSON.stringify({ plan_name: name, units, plan_id: edit_plan_id }) + }) + .then(r => r.json()) + .then(j => { + createAlert(j.message, j.ok ? 'success' : 'error'); + if (j.ok) setTimeout(() => (location.href = '/dashboard'), 1000); }) - .then(res => res.json()) - .then(json => { - createAlert(json.message, json.ok ? 'success' : 'error'); - if (json.ok) setTimeout(() => location.href = '/dashboard', 1000); - }) - .catch(() => createAlert('An error occurred while saving.', 'error')); - }); - - - function createAlert(message, category) { - const map = { - success: { - bar: 'bg-green-300 dark:bg-green-800', - bg: 'bg-green-50 dark:bg-gray-800', - text: 'text-green-800 dark:text-green-400', - icon: '' - }, - error: { - bar: 'bg-red-300 dark:bg-red-800', - bg: 'bg-red-50 dark:bg-gray-800', - text: 'text-red-800 dark:text-red-400', - icon: '' - } - }; - - const cfg = map[category] - - const container = document.getElementById('flash-container'); - if (!container) return; - - const nextId = container.querySelectorAll('[data-flash-id]').length + 1; - - const alert = document.createElement('div'); - alert.setAttribute('id', `flash-${nextId}`); - alert.setAttribute('data-flash-id', nextId); - alert.setAttribute('role', 'alert'); - alert.className = ` - relative overflow-hidden flex items-center p-4 mb-4 text-sm font-medium - ${cfg.text} ${cfg.bg} animate-flash-in - ` - - //same html as in base.html - alert.innerHTML = ` -
- - - -
${message}
- - - `.trim(); - - container.appendChild(alert); - - // auto dismiss(the dismiss function is defined in alert.js) - container.querySelectorAll('[data-flash-id]').forEach(el => { - setTimeout(() => dismiss(el), 5000); - }); + .catch(() => createAlert('An error occurred while saving.', 'error')); + }); + + function createAlert(message, category) { + const map = { + success: { + bar: 'bg-green-300 dark:bg-green-800', + bg: 'bg-green-50 dark:bg-gray-800', + text: 'text-green-800 dark:text-green-400', + icon: '' + }, + error: { + bar: 'bg-red-300 dark:bg-red-800', + bg: 'bg-red-50 dark:bg-gray-800', + text: 'text-red-800 dark:text-red-400', + icon: '' } + }; + + const cfg = map[category] + + const container = document.getElementById('flash-container'); + if (!container) return; + + const nextId = container.querySelectorAll('[data-flash-id]').length + 1; + + const alert = document.createElement('div'); + alert.setAttribute('id', `flash-${nextId}`); + alert.setAttribute('data-flash-id', nextId); + alert.setAttribute('role', 'alert'); + alert.className = ` + relative overflow-hidden flex items-center p-4 mb-4 text-sm font-medium + ${cfg.text} ${cfg.bg} animate-flash-in + ` + + //same html as in base.html + alert.innerHTML = ` +
+ + + +
${message}
+ + + `.trim(); + + container.appendChild(alert); + + // auto dismiss(the dismiss function is defined in alert.js) + container.querySelectorAll('[data-flash-id]').forEach(el => { + setTimeout(() => dismiss(el), 5000); + }); } - - // ───── Kickoff ───── - init(); -}); +} From 3a565a20547ca09865d9e21902b16c392130d4c9 Mon Sep 17 00:00:00 2001 From: Hugo Smith Date: Fri, 16 May 2025 14:43:52 +0800 Subject: [PATCH 2/2] Dragndrop functionality working --- src/app/static/js/create.js | 40 +++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/app/static/js/create.js b/src/app/static/js/create.js index 695095a..3d85f05 100644 --- a/src/app/static/js/create.js +++ b/src/app/static/js/create.js @@ -108,6 +108,8 @@ function byCode(code) { return allUnits.find(u => u.unit_code === code); } +const keyOf = cell => cell?.dataset.key || ''; + function createUnitDiv(unit) { const div = document.createElement('div'); div.className = [ @@ -310,6 +312,7 @@ function applyTemplate(template) { // — drag & drop — function dragStartHandler(e) { e.dataTransfer.setData('text/plain', this.dataset.code); + e.dataTransfer.setData('sourceKey', this.closest(CELL_SELECTOR)?.dataset.key || ''); e.dataTransfer.effectAllowed = 'move'; setTimeout(() => this.classList.add('opacity-50')); } @@ -354,24 +357,35 @@ function setupDragAndDrop() { cell.addEventListener('drop', e => { e.preventDefault(); const code = e.dataTransfer.getData('text/plain'); + const sourceKey = e.dataTransfer.getData('sourceKey'); if (!code) return; - const existing = document.querySelector(`${CELL_SELECTOR} .unit[data-code="${code}"]`); - if (existing) { - const oldCell = existing.closest(CELL_SELECTOR); - existing.remove(); - oldCell.innerHTML = placeholderHtml(); - delete placedUnits[oldCell.dataset.key]; + if (keyOf(cell) === sourceKey) return; + + const existingDiv = cell.querySelector('.unit'); + const targetKey = keyOf(cell); + + const draggedUnit = sourceKey + ? document.querySelector(`${CELL_SELECTOR}[data-key="${sourceKey}"] .unit[data-code="${code}"]`) + : null; + if (draggedUnit) { + draggedUnit.remove(); + document.querySelector(`${CELL_SELECTOR}[data-key="${sourceKey}"]`).innerHTML = placeholderHtml(); } - const fromList = DOM.unitList.querySelector(`.unit[data-code="${code}"]`); - if (fromList) fromList.remove(); - cell.innerHTML = ''; - const unit = byCode(code); - if (!unit) return; - cell.appendChild(createUnitDiv(unit)); - placedUnits[cell.dataset.key] = code; + cell.appendChild(draggedUnit || createUnitDiv(byCode(code))); + placedUnits[targetKey] = code; + + // if the target was occupied, move that unit back to source cell (swap) + if (existingDiv && sourceKey) { + const srcCell = document.querySelector(`${CELL_SELECTOR}[data-key="${sourceKey}"]`); + srcCell.innerHTML = ''; + srcCell.appendChild(existingDiv); + placedUnits[sourceKey] = existingDiv.dataset.code; + } else if (existingDiv) { + DOM.unitList.appendChild(existingDiv); + } resetAvailableUnits(); renderUnitList(availableUnits);