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 + });