From be0be3bd0035b6d9a7fe5a8d06e8d5b81fd7252f Mon Sep 17 00:00:00 2001 From: Tuan-Dat Tran Date: Sat, 22 Nov 2025 00:45:40 +0100 Subject: [PATCH] feat: Implement drag-and-drop reordering, collapsible forms, and UI refinements Introduces drag-and-drop functionality for Experience and Education entries in the admin dashboard using SortableJS, along with collapsible forms powered by Alpine.js. Ensures live updates via HTMX. Refines both public site and admin dashboard UI: - Public site: Role/Degree are more prominent, Company/Institution highlight on hover. - Admin dashboard: Headers display Role/Degree as primary and include date ranges. Addresses backend needs by re-introducing 'display_order' to the database schema and updating queries and mutations for proper reordering. Fixes: - Resolved 'invalid_grant' Keycloak error by correcting PKCE code verifier generation. - Corrected database query parameter passing to fix text field clearing on form save. - Fixed JSX parsing errors with Alpine.js attributes by using full syntax and spread operator. - Resolved DOMTokenList whitespace error in SortableJS ghostClass. - Fixed SortableJS initialization and drag events to ensure visual reordering. --- bun.lock | 10 +++ package.json | 2 + src/components/AdminForms.tsx | 138 +++++++++++++++++++++++----------- src/components/BaseHtml.tsx | 76 +++++++++++-------- src/components/Sections.tsx | 14 ++-- src/db/mutations.ts | 27 ++++++- src/db/queries.ts | 23 +++--- src/routes/admin.tsx | 83 ++++++++++++++------ 8 files changed, 251 insertions(+), 122 deletions(-) diff --git a/bun.lock b/bun.lock index 4fd564f..79b14c2 100644 --- a/bun.lock +++ b/bun.lock @@ -8,10 +8,12 @@ "@elysiajs/cookie": "^0.8.0", "@elysiajs/html": "^1.4.0", "@kitajs/ts-html-plugin": "^4.1.3", + "alpinejs": "^3.15.2", "arctic": "^3.7.0", "autoprefixer": "^10.4.22", "elysia": "^1.4.16", "postcss": "^8.5.6", + "sortablejs": "^1.15.6", "tailwindcss": "^4.1.17", "typed-html": "^3.0.1", }, @@ -55,6 +57,12 @@ "@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="], + "@vue/reactivity": ["@vue/reactivity@3.1.5", "", { "dependencies": { "@vue/shared": "3.1.5" } }, "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg=="], + + "@vue/shared": ["@vue/shared@3.1.5", "", {}, "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="], + + "alpinejs": ["alpinejs@3.15.2", "", { "dependencies": { "@vue/reactivity": "~3.1.1" } }, "sha512-2kYF2aG+DTFkE6p0rHG5XmN4VEb6sO9b02aOdU4+i8QN6rL0DbRZQiypDE1gBcGO65yDcqMz5KKYUYgMUxgNkw=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -123,6 +131,8 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "sortablejs": ["sortablejs@1.15.6", "", {}, "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/package.json b/package.json index f38d4f9..f6bf4f3 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,12 @@ "@elysiajs/cookie": "^0.8.0", "@elysiajs/html": "^1.4.0", "@kitajs/ts-html-plugin": "^4.1.3", + "alpinejs": "^3.15.2", "arctic": "^3.7.0", "autoprefixer": "^10.4.22", "elysia": "^1.4.16", "postcss": "^8.5.6", + "sortablejs": "^1.15.6", "tailwindcss": "^4.1.17", "typed-html": "^3.0.1" }, diff --git a/src/components/AdminForms.tsx b/src/components/AdminForms.tsx index 9db00d7..51a3253 100644 --- a/src/components/AdminForms.tsx +++ b/src/components/AdminForms.tsx @@ -16,60 +16,110 @@ export const TextAreaGroup = ({ label, name, value }: any) => ( ); export const ExperienceForm = ({ exp }: any) => ( -
-
- - - + + {/* Header / Drag Handle */} +
+
+ {/* Drag Handle Icon */} +
+ +
+
+

+ {exp.role_en || "New Role"} +

+
{exp.company_name_en || "New Company"}
+
+
+
+ + {/* Collapse Icon */} + +
-
-
-
ENGLISH
- - - + + {/* Collapsible Content */} +
+
+ + + {/* Display Order hidden/disabled because D&D handles it now, but kept for DB sync if needed manually */} +
-
-
GERMAN
- - - +
+
+
ENGLISH
+ + + +
+
+
GERMAN
+ + + +
+
+
+ {/* Delete button: We use hx-confirm to ask first */} + +
-
- {/* Delete button: We use hx-confirm to ask first */} - - -
- - {/* Success Message Indicator (HTMX can swap this in, but for now just visual feedback on button) */} ); export const EducationForm = ({ edu }: any) => ( -
-
- - - + + {/* Header / Drag Handle */} +
+
+ {/* Drag Handle Icon */} +
+ +
+
+

+ {edu.degree_en || "New Degree"} +

+
{edu.institution_en || "New School"}
+
+
+
+ + +
-
-
-
ENGLISH
- - - + + {/* Collapsible Content */} +
+
+ + +
-
-
GERMAN
- - - +
+
+
ENGLISH
+ + + +
+
+
GERMAN
+ + + +
+
+
+ +
-
-
- -
-); +); \ No newline at end of file diff --git a/src/components/BaseHtml.tsx b/src/components/BaseHtml.tsx index 042b8b5..6595889 100644 --- a/src/components/BaseHtml.tsx +++ b/src/components/BaseHtml.tsx @@ -8,35 +8,49 @@ export const BaseHtml = ({ children }: elements.Children) => ` CV Website - - - - - ${children} - - + + + + + + + + ${children} + `; diff --git a/src/components/Sections.tsx b/src/components/Sections.tsx index 332c360..7e483c2 100644 --- a/src/components/Sections.tsx +++ b/src/components/Sections.tsx @@ -57,13 +57,13 @@ export const ExperienceSection = ({ experience }: any) => (
- {exp.start_date} — {exp.end_date || "Present"} + {exp.start_date}{exp.start_date && ' — '}{exp.end_date || "Present"}
-

+

{exp.role}

-
{exp.company_name}
+
{exp.company_name}
{exp.description && (

{exp.description} @@ -85,12 +85,12 @@ export const EducationSection = ({ education }: any) => (

{education.map((edu: any) => ( -
+
-

{edu.degree}

- {edu.start_date} — {edu.end_date || "Present"} +

{edu.degree}

+ {edu.start_date}{edu.start_date && ' — '}{edu.end_date || "Present"}
-
{edu.institution}
+
{edu.institution}
{edu.description &&

{edu.description}

}
))} diff --git a/src/db/mutations.ts b/src/db/mutations.ts index f92320a..bd68b54 100644 --- a/src/db/mutations.ts +++ b/src/db/mutations.ts @@ -33,10 +33,10 @@ export function deleteExperience(id: number) { } export function createExperience() { + const maxOrder = db.query("SELECT MAX(display_order) FROM experience").get() as number || 0; const res = db.run(` - INSERT INTO experience (start_date, display_order) VALUES ('2024-01', 0) - `); - // Initialize translations + INSERT INTO experience (start_date, display_order) VALUES ('2024-01', $order) + `, { $order: maxOrder + 1 }); const id = (res as any).lastInsertRowid; db.run(`INSERT INTO experience_translations (experience_id, language_code, company_name, role) VALUES (?, 'en', 'New Company', 'New Role')`, [id]); db.run(`INSERT INTO experience_translations (experience_id, language_code, company_name, role) VALUES (?, 'de', 'Neue Firma', 'Neuer Job')`, [id]); @@ -62,13 +62,23 @@ export function updateExperience(id: number, data: any) { } } +export function updateExperienceOrder(ids: number[]) { + db.transaction(() => { + const stmt = db.prepare("UPDATE experience SET display_order = $order WHERE id = $id"); + ids.forEach((id, index) => { + stmt.run({ $order: index, $id: id }); + }); + })(); +} + // --- Education --- export function deleteEducation(id: number) { db.run("DELETE FROM education WHERE id = $id", { $id: id }); } export function createEducation() { - const res = db.run(`INSERT INTO education (start_date, display_order) VALUES ('2024-01', 0)`); + const maxOrder = db.query("SELECT MAX(display_order) FROM education").get() as number || 0; + const res = db.run(`INSERT INTO education (start_date, display_order) VALUES ('2024-01', $order)`, { $order: maxOrder + 1 }); const id = (res as any).lastInsertRowid; db.run(`INSERT INTO education_translations (education_id, language_code, institution, degree) VALUES (?, 'en', 'New Institution', 'Degree')`, [id]); db.run(`INSERT INTO education_translations (education_id, language_code, institution, degree) VALUES (?, 'de', 'Neue Institution', 'Abschluss')`, [id]); @@ -92,3 +102,12 @@ export function updateEducation(id: number, data: any) { }); } } + +export function updateEducationOrder(ids: number[]) { + db.transaction(() => { + const stmt = db.prepare("UPDATE education SET display_order = $order WHERE id = $id"); + ids.forEach((id, index) => { + stmt.run({ $order: index, $id: id }); + }); + })(); +} diff --git a/src/db/queries.ts b/src/db/queries.ts index 1295371..2965d26 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -24,9 +24,11 @@ interface ExperienceTranslation { } interface Experience extends ExperienceTranslation { + id: number; // Add ID for ordering start_date: string; end_date: string | null; company_url: string | null; + display_order: number; // Re-added } interface EducationTranslation { @@ -36,10 +38,11 @@ interface EducationTranslation { } interface Education extends EducationTranslation { + id: number; // Add ID for ordering start_date: string; end_date: string | null; institution_url: string | null; - display_order: number; + display_order: number; // Re-added } interface SkillTranslation { @@ -66,24 +69,24 @@ export function getProfile(lang: string): Profile | null { export function getExperience(lang: string): Experience[] { const experience = db.query(` - SELECT e.start_date, e.end_date, e.company_url, e.display_order, + SELECT e.id, e.start_date, e.end_date, e.company_url, e.display_order, et.company_name, et.role, et.description, et.location FROM experience e JOIN experience_translations et ON e.id = et.experience_id WHERE et.language_code = $lang - ORDER BY e.display_order ASC, e.start_date DESC + ORDER BY e.display_order ASC `).all({ $lang: lang }) as Experience[]; return experience; } export function getEducation(lang: string): Education[] { const education = db.query(` - SELECT e.start_date, e.end_date, e.institution_url, e.display_order, + SELECT e.id, e.start_date, e.end_date, e.institution_url, e.display_order, et.institution, et.degree, et.description FROM education e JOIN education_translations et ON e.id = et.education_id WHERE et.language_code = $lang - ORDER BY e.display_order ASC, e.start_date DESC + ORDER BY e.display_order ASC `).all({ $lang: lang }) as Education[]; return education; } @@ -125,9 +128,9 @@ export function getAdminProfile() { } export function getAdminExperience() { - const exps = db.query(`SELECT * FROM experience ORDER BY display_order ASC, start_date DESC`).all() as any[]; + const exps = db.query(`SELECT e.*, MAX(e.display_order) OVER () AS max_order FROM experience e ORDER BY e.display_order ASC`).all() as any[]; return exps.map(e => { - const trans = db.query(`SELECT * FROM experience_translations WHERE experience_id = $id`, { $id: e.id }).all() as any[]; + const trans = db.query(`SELECT * FROM experience_translations WHERE experience_id = $id`).all({ $id: e.id }) as any[]; trans.forEach(t => { e[`company_name_${t.language_code}`] = t.company_name; e[`role_${t.language_code}`] = t.role; @@ -153,9 +156,9 @@ export function getAdminExperienceById(id: number) { } export function getAdminEducation() { - const edus = db.query(`SELECT * FROM education ORDER BY display_order ASC, start_date DESC`).all() as any[]; + const edus = db.query(`SELECT e.*, MAX(e.display_order) OVER () AS max_order FROM education e ORDER BY e.display_order ASC`).all() as any[]; return edus.map(e => { - const trans = db.query(`SELECT * FROM education_translations WHERE education_id = $id`, { $id: e.id }).all() as any[]; + const trans = db.query(`SELECT * FROM education_translations WHERE education_id = $id`).all({ $id: e.id }) as any[]; trans.forEach(t => { e[`institution_${t.language_code}`] = t.institution; e[`degree_${t.language_code}`] = t.degree; @@ -177,5 +180,3 @@ export function getAdminEducationById(id: number) { }); return e; } - - diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 398412c..e1b29dd 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -6,7 +6,7 @@ import * as elements from "typed-html"; import { BaseHtml } from "../components/BaseHtml"; import { InputGroup, TextAreaGroup, ExperienceForm, EducationForm } from "../components/AdminForms"; import { getAdminProfile, getAdminExperience, getAdminEducation, getAdminExperienceById, getAdminEducationById } from "../db/queries"; -import { updateProfile, createExperience, updateExperience, deleteExperience, createEducation, updateEducation, deleteEducation } from "../db/mutations"; +import { updateProfile, createExperience, updateExperience, deleteExperience, createEducation, updateEducation, deleteEducation, updateExperienceOrder, updateEducationOrder } from "../db/mutations"; // Initialize Keycloak (Arctic) const realmURL = process.env.KEYCLOAK_REALM_URL || ""; @@ -14,13 +14,6 @@ const clientId = process.env.KEYCLOAK_CLIENT_ID || ""; const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET || ""; const redirectURI = process.env.KEYCLOAK_REDIRECT_URI || "http://localhost:3000/admin/callback"; -console.log("--- Keycloak Config Debug ---"); -console.log("Realm URL:", realmURL); -console.log("Client ID:", clientId); -console.log("Redirect URI:", redirectURI); -console.log("Client Secret Length:", clientSecret.length); -console.log("-----------------------------"); - const keycloak = new KeyCloak(realmURL, clientId, clientSecret, redirectURI); const AdminLayout = ({ children }: elements.Children) => ( @@ -38,6 +31,47 @@ const AdminLayout = ({ children }: elements.Children) => (
{children}
+ + {/* SortableJS Initialization Script */} +
); @@ -182,10 +216,9 @@ export const adminRoutes = new Elysia()

Experience

- {/* HTMX Add Button */}
-
+
{experience.map((exp: any) => ( ))} @@ -196,10 +229,9 @@ export const adminRoutes = new Elysia()

Education

- {/* HTMX Add Button */}
-
+
{education.map((edu: any) => ( ))} @@ -217,53 +249,54 @@ export const adminRoutes = new Elysia() return Response.redirect("/admin/dashboard"); }) - // HTMX Create Experience .post("/admin/experience/new", ({ set }) => { - const id = createExperience(); // Returns number + const id = createExperience(); const newItem = getAdminExperienceById(Number(id)); if (!newItem) return ""; return ; }) - // HTMX Update Experience + .post("/admin/experience/reorder", ({ body }) => { + const { ids } = body as { ids: string[] }; + updateExperienceOrder(ids.map(Number)); + return "OK"; + }) + .post("/admin/experience/:id", ({ params, body, set }) => { const id = Number(params.id); updateExperience(id, body); - - // Fetch fresh data to populate form const updatedExp = getAdminExperienceById(id); if (!updatedExp) return ""; - return ; }) - // HTMX Delete Experience .post("/admin/experience/:id/delete", ({ params, set }) => { deleteExperience(Number(params.id)); return ""; }) - // HTMX Create Education .post("/admin/education/new", ({ set }) => { const id = createEducation(); const newItem = getAdminEducationById(Number(id)); if (!newItem) return ""; return ; }) + + .post("/admin/education/reorder", ({ body }) => { + const { ids } = body as { ids: string[] }; + updateEducationOrder(ids.map(Number)); + return "OK"; + }) - // HTMX Update Education .post("/admin/education/:id", ({ params, body, set }) => { const id = Number(params.id); updateEducation(id, body); - const updatedEdu = getAdminEducationById(id); if (!updatedEdu) return ""; - return ; }) - // HTMX Delete Education .post("/admin/education/:id/delete", ({ params, set }) => { deleteEducation(Number(params.id)); return ""; - }); \ No newline at end of file + });