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.
This commit is contained in:
Tuan-Dat Tran
2025-11-22 00:45:40 +01:00
parent 3c990e5ab6
commit be0be3bd00
8 changed files with 251 additions and 122 deletions

View File

@@ -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=="],

View File

@@ -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"
},

View File

@@ -16,60 +16,110 @@ export const TextAreaGroup = ({ label, name, value }: any) => (
);
export const ExperienceForm = ({ exp }: any) => (
<form hx-post={`/admin/experience/${exp.id}`} hx-target="this" hx-swap="outerHTML" class="border dark:border-gray-700 p-4 rounded mb-6 relative group transition-all duration-300 hover:border-blue-300">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<InputGroup label="Start Date" name="start_date" value={exp.start_date} />
<InputGroup label="End Date" name="end_date" value={exp.end_date} />
<InputGroup label="Order" name="display_order" value={exp.display_order} type="number" />
<form hx-post={`/admin/experience/${exp.id}`} hx-target="this" hx-swap="outerHTML" data-id={exp.id} class="border dark:border-gray-700 rounded mb-2 bg-white dark:bg-gray-800 shadow-sm" x-data="{ open: false }">
{/* Header / Drag Handle */}
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 cursor-pointer select-none group" x-on:click="if (!$event.target.closest('.drag-handle')) open = !open">
<div class="flex items-center gap-3">
{/* Drag Handle Icon */}
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 p-1">
<svg class="w-5 h-5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"></path></svg>
</div>
<div>
<h3 class="font-bold text-gray-800 dark:text-white">
{exp.role_en || "New Role"}
</h3>
<div class="text-sm text-gray-500">{exp.company_name_en || "New Company"}</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="text-sm text-gray-500 font-mono hidden sm:block">
{exp.start_date}{exp.start_date && ' — '}{exp.end_date || "Present"}
</div>
{/* Collapse Icon */}
<svg class="w-5 h-5 text-gray-500 transition-transform duration-200" x-bind:class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
<InputGroup label="Company" name="company_name_en" value={exp.company_name_en} />
<InputGroup label="Role" name="role_en" value={exp.role_en} />
<TextAreaGroup label="Description" name="description_en" value={exp.description_en} />
{/* Collapsible Content */}
<div x-show="open" class="p-4 border-t dark:border-gray-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<InputGroup label="Start Date" name="start_date" value={exp.start_date} />
<InputGroup label="End Date" name="end_date" value={exp.end_date} />
{/* Display Order hidden/disabled because D&D handles it now, but kept for DB sync if needed manually */}
<input type="hidden" name="display_order" value={exp.display_order} />
</div>
<div>
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
<InputGroup label="Company" name="company_name_de" value={exp.company_name_de} />
<InputGroup label="Role" name="role_de" value={exp.role_de} />
<TextAreaGroup label="Description" name="description_de" value={exp.description_de} />
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
<InputGroup label="Company" name="company_name_en" value={exp.company_name_en} />
<InputGroup label="Role" name="role_en" value={exp.role_en} />
<TextAreaGroup label="Description" name="description_en" value={exp.description_en} />
</div>
<div>
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
<InputGroup label="Company" name="company_name_de" value={exp.company_name_de} />
<InputGroup label="Role" name="role_de" value={exp.role_de} />
<TextAreaGroup label="Description" name="description_de" value={exp.description_de} />
</div>
</div>
<div class="flex justify-between mt-2">
{/* Delete button: We use hx-confirm to ask first */}
<button hx-post={`/admin/experience/${exp.id}/delete`} hx-confirm="Are you sure?" hx-target="closest form" hx-swap="outerHTML" class="text-red-500 text-sm hover:underline">Delete</button>
<button type="submit" class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600 transition-transform active:scale-95">Save</button>
</div>
</div>
<div class="flex justify-between mt-2">
{/* Delete button: We use hx-confirm to ask first */}
<button hx-post={`/admin/experience/${exp.id}/delete`} hx-confirm="Are you sure?" hx-target="closest form" hx-swap="outerHTML" class="text-red-500 text-sm hover:underline">Delete</button>
<button type="submit" class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600 transition-transform active:scale-95">Save</button>
</div>
{/* Success Message Indicator (HTMX can swap this in, but for now just visual feedback on button) */}
</form>
);
export const EducationForm = ({ edu }: any) => (
<form hx-post={`/admin/education/${edu.id}`} hx-target="this" hx-swap="outerHTML" class="border dark:border-gray-700 p-4 rounded mb-6 relative hover:border-blue-300 transition-colors">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<InputGroup label="Start Date" name="start_date" value={edu.start_date} />
<InputGroup label="End Date" name="end_date" value={edu.end_date} />
<InputGroup label="Order" name="display_order" value={edu.display_order} type="number" />
<form hx-post={`/admin/education/${edu.id}`} hx-target="this" hx-swap="outerHTML" data-id={edu.id} class="border dark:border-gray-700 rounded mb-2 bg-white dark:bg-gray-800 shadow-sm" x-data="{ open: false }">
{/* Header / Drag Handle */}
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 cursor-pointer select-none group" x-on:click="if (!$event.target.closest('.drag-handle')) open = !open">
<div class="flex items-center gap-3">
{/* Drag Handle Icon */}
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 p-1">
<svg class="w-5 h-5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"></path></svg>
</div>
<div>
<h3 class="font-bold text-gray-800 dark:text-white">
{edu.degree_en || "New Degree"}
</h3>
<div class="text-sm text-gray-500">{edu.institution_en || "New School"}</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="text-sm text-gray-500 font-mono hidden sm:block">
{edu.start_date}{edu.start_date && ' — '}{edu.end_date || "Present"}
</div>
<svg class="w-5 h-5 text-gray-500 transition-transform duration-200" x-bind:class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
<InputGroup label="Institution" name="institution_en" value={edu.institution_en} />
<InputGroup label="Degree" name="degree_en" value={edu.degree_en} />
<TextAreaGroup label="Description" name="description_en" value={edu.description_en} />
{/* Collapsible Content */}
<div x-show="open" class="p-4 border-t dark:border-gray-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<InputGroup label="Start Date" name="start_date" value={edu.start_date} />
<InputGroup label="End Date" name="end_date" value={edu.end_date} />
<input type="hidden" name="display_order" value={edu.display_order} />
</div>
<div>
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
<InputGroup label="Institution" name="institution_de" value={edu.institution_de} />
<InputGroup label="Degree" name="degree_de" value={edu.degree_de} />
<TextAreaGroup label="Description" name="description_de" value={edu.description_de} />
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
<InputGroup label="Institution" name="institution_en" value={edu.institution_en} />
<InputGroup label="Degree" name="degree_en" value={edu.degree_en} />
<TextAreaGroup label="Description" name="description_en" value={edu.description_en} />
</div>
<div>
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
<InputGroup label="Institution" name="institution_de" value={edu.institution_de} />
<InputGroup label="Degree" name="degree_de" value={edu.degree_de} />
<TextAreaGroup label="Description" name="description_de" value={edu.description_de} />
</div>
</div>
<div class="flex justify-between mt-2">
<button hx-post={`/admin/education/${edu.id}/delete`} hx-confirm="Are you sure?" hx-target="closest form" hx-swap="outerHTML" class="text-red-500 text-sm hover:underline">Delete</button>
<button type="submit" class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600 transition-transform active:scale-95">Save</button>
</div>
</div>
<div class="flex justify-between mt-2">
<button hx-post={`/admin/education/${edu.id}/delete`} hx-confirm="Are you sure?" hx-target="closest form" hx-swap="outerHTML" class="text-red-500 text-sm hover:underline">Delete</button>
<button type="submit" class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600 transition-transform active:scale-95">Save</button>
</div>
</form>
);
);

View File

@@ -8,35 +8,49 @@ export const BaseHtml = ({ children }: elements.Children) => `
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CV Website</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {},
},
}
// Dark mode toggle script
const storedTheme = localStorage.getItem('theme');
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
function toggleTheme() {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
}
</script>
</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors duration-300">
${children}
</body>
</html>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.2/dist/cdn.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {},
},
}
// Dark mode toggle script
const storedTheme = localStorage.getItem('theme');
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
function toggleTheme() {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
}
</script>
<style>
/* SortableJS drag feedback */
.sortable-chosen {
cursor: grabbing !important;
box-shadow: 0 4px 15px rgba(0,0,0,0.2); /* Lift effect */
opacity: 0.8; /* Slight transparency */
}
/* Class for the ghost/placeholder when item is dragged */
.sortable-ghost {
background-color: theme('colors.blue.50'); /* Lighter background */
border: 1px dashed theme('colors.blue.300'); /* Dashed border */
}
</style>
</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors duration-300" x-data>
${children}
</body></html>
`;

View File

@@ -57,13 +57,13 @@ export const ExperienceSection = ({ experience }: any) => (
<div class="grid md:grid-cols-[1fr_3fr] gap-4">
<div class="text-sm text-gray-500 dark:text-gray-400 pt-1 font-mono">
{exp.start_date} {exp.end_date || "Present"}
{exp.start_date}{exp.start_date && ' — '}{exp.end_date || "Present"}
</div>
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
<h3 class="text-xl font-bold text-blue-600 dark:text-blue-400">
{exp.role}
</h3>
<div class="text-blue-600 dark:text-blue-400 font-medium mb-2">{exp.company_name}</div>
<div class="text-gray-900 dark:text-white font-medium mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{exp.company_name}</div>
{exp.description && (
<p class="text-gray-600 dark:text-gray-300 leading-relaxed text-base">
{exp.description}
@@ -85,12 +85,12 @@ export const EducationSection = ({ education }: any) => (
</h2>
<div class="grid gap-6">
{education.map((edu: any) => (
<div class="bg-gray-50 dark:bg-gray-800/50 p-6 rounded-xl border border-gray-100 dark:border-gray-700 hover:border-purple-500/30 transition-colors">
<div class="bg-gray-50 dark:bg-gray-800/50 p-6 rounded-xl border border-gray-100 dark:border-gray-700 hover:border-purple-500/30 transition-colors group">
<div class="flex flex-col md:flex-row md:justify-between md:items-baseline mb-2">
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{edu.degree}</h3>
<span class="text-sm text-gray-500 font-mono">{edu.start_date} {edu.end_date || "Present"}</span>
<h3 class="text-lg font-bold text-purple-600 dark:text-purple-400">{edu.degree}</h3>
<span class="text-sm text-gray-500 font-mono">{edu.start_date}{edu.start_date && ' — '}{edu.end_date || "Present"}</span>
</div>
<div class="text-purple-600 dark:text-purple-400 font-medium">{edu.institution}</div>
<div class="text-gray-900 dark:text-white font-medium group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors">{edu.institution}</div>
{edu.description && <p class="mt-2 text-gray-600 dark:text-gray-400 text-sm">{edu.description}</p>}
</div>
))}

View File

@@ -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 });
});
})();
}

View File

@@ -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;
}

View File

@@ -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) => (
<div class="container mx-auto p-4">
{children}
</div>
{/* SortableJS Initialization Script */}
<script>
{`
function initSortable(id, endpoint) {
const el = document.getElementById(id);
if (!el) {
console.warn('Sortable container not found:', id);
return;
}
if (el._sortable) return;
el._sortable = new Sortable(el, {
group: id,
draggable: 'form',
handle: '.drag-handle',
animation: 150,
ghostClass: 'bg-blue-100',
onEnd: function (evt) {
const ids = Array.from(el.querySelectorAll('form')).map(f => f.getAttribute('data-id'));
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ids })
});
}
});
}
document.addEventListener('DOMContentLoaded', () => {
initSortable('experience-list', '/admin/experience/reorder');
initSortable('education-list', '/admin/education/reorder');
});
document.body.addEventListener('htmx:afterSwap', (evt) => {
initSortable('experience-list', '/admin/experience/reorder');
initSortable('education-list', '/admin/education/reorder');
});
`}
</script>
</div>
);
@@ -182,10 +216,9 @@ export const adminRoutes = new Elysia()
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold">Experience</h2>
{/* HTMX Add Button */}
<button hx-post="/admin/experience/new" hx-target="#experience-list" hx-swap="beforeend" class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">+ Add Job</button>
</div>
<div id="experience-list" class="space-y-6">
<div id="experience-list" class="">
{experience.map((exp: any) => (
<ExperienceForm exp={exp} />
))}
@@ -196,10 +229,9 @@ export const adminRoutes = new Elysia()
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold">Education</h2>
{/* HTMX Add Button */}
<button hx-post="/admin/education/new" hx-target="#education-list" hx-swap="beforeend" class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">+ Add Education</button>
</div>
<div id="education-list" class="space-y-6">
<div id="education-list" class="">
{education.map((edu: any) => (
<EducationForm edu={edu} />
))}
@@ -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 <ExperienceForm exp={newItem} />;
})
// 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 <ExperienceForm exp={updatedExp} />;
})
// 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 <EducationForm edu={newItem} />;
})
.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 <EducationForm edu={updatedEdu} />;
})
// HTMX Delete Education
.post("/admin/education/:id/delete", ({ params, set }) => {
deleteEducation(Number(params.id));
return "";
});
});