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:
10
bun.lock
10
bun.lock
@@ -8,10 +8,12 @@
|
|||||||
"@elysiajs/cookie": "^0.8.0",
|
"@elysiajs/cookie": "^0.8.0",
|
||||||
"@elysiajs/html": "^1.4.0",
|
"@elysiajs/html": "^1.4.0",
|
||||||
"@kitajs/ts-html-plugin": "^4.1.3",
|
"@kitajs/ts-html-plugin": "^4.1.3",
|
||||||
|
"alpinejs": "^3.15.2",
|
||||||
"arctic": "^3.7.0",
|
"arctic": "^3.7.0",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"elysia": "^1.4.16",
|
"elysia": "^1.4.16",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"sortablejs": "^1.15.6",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"typed-html": "^3.0.1",
|
"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=="],
|
"@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-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
"@elysiajs/cookie": "^0.8.0",
|
"@elysiajs/cookie": "^0.8.0",
|
||||||
"@elysiajs/html": "^1.4.0",
|
"@elysiajs/html": "^1.4.0",
|
||||||
"@kitajs/ts-html-plugin": "^4.1.3",
|
"@kitajs/ts-html-plugin": "^4.1.3",
|
||||||
|
"alpinejs": "^3.15.2",
|
||||||
"arctic": "^3.7.0",
|
"arctic": "^3.7.0",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"elysia": "^1.4.16",
|
"elysia": "^1.4.16",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"sortablejs": "^1.15.6",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"typed-html": "^3.0.1"
|
"typed-html": "^3.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,60 +16,110 @@ export const TextAreaGroup = ({ label, name, value }: any) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ExperienceForm = ({ exp }: 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">
|
<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 }">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
{/* Header / Drag Handle */}
|
||||||
<InputGroup label="Start Date" name="start_date" value={exp.start_date} />
|
<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">
|
||||||
<InputGroup label="End Date" name="end_date" value={exp.end_date} />
|
<div class="flex items-center gap-3">
|
||||||
<InputGroup label="Order" name="display_order" value={exp.display_order} type="number" />
|
{/* Drag Handle Icon */}
|
||||||
</div>
|
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 p-1">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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>
|
||||||
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
|
<div>
|
||||||
<InputGroup label="Company" name="company_name_en" value={exp.company_name_en} />
|
<h3 class="font-bold text-gray-800 dark:text-white">
|
||||||
<InputGroup label="Role" name="role_en" value={exp.role_en} />
|
{exp.role_en || "New Role"}
|
||||||
<TextAreaGroup label="Description" name="description_en" value={exp.description_en} />
|
</h3>
|
||||||
</div>
|
<div class="text-sm text-gray-500">{exp.company_name_en || "New Company"}</div>
|
||||||
<div>
|
</div>
|
||||||
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
|
</div>
|
||||||
<InputGroup label="Company" name="company_name_de" value={exp.company_name_de} />
|
<div class="flex items-center gap-4">
|
||||||
<InputGroup label="Role" name="role_de" value={exp.role_de} />
|
<div class="text-sm text-gray-500 font-mono hidden sm:block">
|
||||||
<TextAreaGroup label="Description" name="description_de" value={exp.description_de} />
|
{exp.start_date}{exp.start_date && ' — '}{exp.end_date || "Present"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/* Collapse Icon */}
|
||||||
<div class="flex justify-between mt-2">
|
<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>
|
||||||
{/* Delete button: We use hx-confirm to ask first */}
|
</div>
|
||||||
<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>
|
||||||
|
|
||||||
{/* Success Message Indicator (HTMX can swap this in, but for now just visual feedback on button) */}
|
{/* 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 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>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const EducationForm = ({ edu }: any) => (
|
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">
|
<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 }">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
{/* Header / Drag Handle */}
|
||||||
<InputGroup label="Start Date" name="start_date" value={edu.start_date} />
|
<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">
|
||||||
<InputGroup label="End Date" name="end_date" value={edu.end_date} />
|
<div class="flex items-center gap-3">
|
||||||
<InputGroup label="Order" name="display_order" value={edu.display_order} type="number" />
|
{/* 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>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
{/* Collapsible Content */}
|
||||||
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
|
<div x-show="open" class="p-4 border-t dark:border-gray-700">
|
||||||
<InputGroup label="Institution" name="institution_en" value={edu.institution_en} />
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||||
<InputGroup label="Degree" name="degree_en" value={edu.degree_en} />
|
<InputGroup label="Start Date" name="start_date" value={edu.start_date} />
|
||||||
<TextAreaGroup label="Description" name="description_en" value={edu.description_en} />
|
<InputGroup label="End Date" name="end_date" value={edu.end_date} />
|
||||||
|
<input type="hidden" name="display_order" value={edu.display_order} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
|
<div>
|
||||||
<InputGroup label="Institution" name="institution_de" value={edu.institution_de} />
|
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
|
||||||
<InputGroup label="Degree" name="degree_de" value={edu.degree_de} />
|
<InputGroup label="Institution" name="institution_en" value={edu.institution_en} />
|
||||||
<TextAreaGroup label="Description" name="description_de" value={edu.description_de} />
|
<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>
|
|
||||||
<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>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
@@ -8,35 +8,49 @@ export const BaseHtml = ({ children }: elements.Children) => `
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>CV Website</title>
|
<title>CV Website</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
<script>
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||||
tailwind.config = {
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.2/dist/cdn.min.js"></script>
|
||||||
darkMode: 'class',
|
<script>
|
||||||
theme: {
|
tailwind.config = {
|
||||||
extend: {},
|
darkMode: 'class',
|
||||||
},
|
theme: {
|
||||||
}
|
extend: {},
|
||||||
// Dark mode toggle script
|
},
|
||||||
const storedTheme = localStorage.getItem('theme');
|
}
|
||||||
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
// Dark mode toggle script
|
||||||
document.documentElement.classList.add('dark');
|
const storedTheme = localStorage.getItem('theme');
|
||||||
} else {
|
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.add('dark');
|
||||||
}
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
if (document.documentElement.classList.contains('dark')) {
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
localStorage.setItem('theme', 'light');
|
localStorage.setItem('theme', 'light');
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
localStorage.setItem('theme', 'dark');
|
localStorage.setItem('theme', 'dark');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
<style>
|
||||||
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors duration-300">
|
/* SortableJS drag feedback */
|
||||||
${children}
|
.sortable-chosen {
|
||||||
</body>
|
cursor: grabbing !important;
|
||||||
</html>
|
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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ export const ExperienceSection = ({ experience }: any) => (
|
|||||||
|
|
||||||
<div class="grid md:grid-cols-[1fr_3fr] gap-4">
|
<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">
|
<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>
|
||||||
<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}
|
{exp.role}
|
||||||
</h3>
|
</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 && (
|
{exp.description && (
|
||||||
<p class="text-gray-600 dark:text-gray-300 leading-relaxed text-base">
|
<p class="text-gray-600 dark:text-gray-300 leading-relaxed text-base">
|
||||||
{exp.description}
|
{exp.description}
|
||||||
@@ -85,12 +85,12 @@ export const EducationSection = ({ education }: any) => (
|
|||||||
</h2>
|
</h2>
|
||||||
<div class="grid gap-6">
|
<div class="grid gap-6">
|
||||||
{education.map((edu: any) => (
|
{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">
|
<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>
|
<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.end_date || "Present"}</span>
|
<span class="text-sm text-gray-500 font-mono">{edu.start_date}{edu.start_date && ' — '}{edu.end_date || "Present"}</span>
|
||||||
</div>
|
</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>}
|
{edu.description && <p class="mt-2 text-gray-600 dark:text-gray-400 text-sm">{edu.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ export function deleteExperience(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createExperience() {
|
export function createExperience() {
|
||||||
|
const maxOrder = db.query("SELECT MAX(display_order) FROM experience").get() as number || 0;
|
||||||
const res = db.run(`
|
const res = db.run(`
|
||||||
INSERT INTO experience (start_date, display_order) VALUES ('2024-01', 0)
|
INSERT INTO experience (start_date, display_order) VALUES ('2024-01', $order)
|
||||||
`);
|
`, { $order: maxOrder + 1 });
|
||||||
// Initialize translations
|
|
||||||
const id = (res as any).lastInsertRowid;
|
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 (?, '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]);
|
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 ---
|
// --- Education ---
|
||||||
export function deleteEducation(id: number) {
|
export function deleteEducation(id: number) {
|
||||||
db.run("DELETE FROM education WHERE id = $id", { $id: id });
|
db.run("DELETE FROM education WHERE id = $id", { $id: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEducation() {
|
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;
|
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 (?, 'en', 'New Institution', 'Degree')`, [id]);
|
||||||
db.run(`INSERT INTO education_translations (education_id, language_code, institution, degree) VALUES (?, 'de', 'Neue Institution', 'Abschluss')`, [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 });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ interface ExperienceTranslation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Experience extends ExperienceTranslation {
|
interface Experience extends ExperienceTranslation {
|
||||||
|
id: number; // Add ID for ordering
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
company_url: string | null;
|
company_url: string | null;
|
||||||
|
display_order: number; // Re-added
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EducationTranslation {
|
interface EducationTranslation {
|
||||||
@@ -36,10 +38,11 @@ interface EducationTranslation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Education extends EducationTranslation {
|
interface Education extends EducationTranslation {
|
||||||
|
id: number; // Add ID for ordering
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
institution_url: string | null;
|
institution_url: string | null;
|
||||||
display_order: number;
|
display_order: number; // Re-added
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SkillTranslation {
|
interface SkillTranslation {
|
||||||
@@ -66,24 +69,24 @@ export function getProfile(lang: string): Profile | null {
|
|||||||
|
|
||||||
export function getExperience(lang: string): Experience[] {
|
export function getExperience(lang: string): Experience[] {
|
||||||
const experience = db.query(`
|
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
|
et.company_name, et.role, et.description, et.location
|
||||||
FROM experience e
|
FROM experience e
|
||||||
JOIN experience_translations et ON e.id = et.experience_id
|
JOIN experience_translations et ON e.id = et.experience_id
|
||||||
WHERE et.language_code = $lang
|
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[];
|
`).all({ $lang: lang }) as Experience[];
|
||||||
return experience;
|
return experience;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEducation(lang: string): Education[] {
|
export function getEducation(lang: string): Education[] {
|
||||||
const education = db.query(`
|
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
|
et.institution, et.degree, et.description
|
||||||
FROM education e
|
FROM education e
|
||||||
JOIN education_translations et ON e.id = et.education_id
|
JOIN education_translations et ON e.id = et.education_id
|
||||||
WHERE et.language_code = $lang
|
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[];
|
`).all({ $lang: lang }) as Education[];
|
||||||
return education;
|
return education;
|
||||||
}
|
}
|
||||||
@@ -125,9 +128,9 @@ export function getAdminProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminExperience() {
|
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 => {
|
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 => {
|
trans.forEach(t => {
|
||||||
e[`company_name_${t.language_code}`] = t.company_name;
|
e[`company_name_${t.language_code}`] = t.company_name;
|
||||||
e[`role_${t.language_code}`] = t.role;
|
e[`role_${t.language_code}`] = t.role;
|
||||||
@@ -153,9 +156,9 @@ export function getAdminExperienceById(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminEducation() {
|
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 => {
|
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 => {
|
trans.forEach(t => {
|
||||||
e[`institution_${t.language_code}`] = t.institution;
|
e[`institution_${t.language_code}`] = t.institution;
|
||||||
e[`degree_${t.language_code}`] = t.degree;
|
e[`degree_${t.language_code}`] = t.degree;
|
||||||
@@ -177,5 +180,3 @@ export function getAdminEducationById(id: number) {
|
|||||||
});
|
});
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import * as elements from "typed-html";
|
|||||||
import { BaseHtml } from "../components/BaseHtml";
|
import { BaseHtml } from "../components/BaseHtml";
|
||||||
import { InputGroup, TextAreaGroup, ExperienceForm, EducationForm } from "../components/AdminForms";
|
import { InputGroup, TextAreaGroup, ExperienceForm, EducationForm } from "../components/AdminForms";
|
||||||
import { getAdminProfile, getAdminExperience, getAdminEducation, getAdminExperienceById, getAdminEducationById } from "../db/queries";
|
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)
|
// Initialize Keycloak (Arctic)
|
||||||
const realmURL = process.env.KEYCLOAK_REALM_URL || "";
|
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 clientSecret = process.env.KEYCLOAK_CLIENT_SECRET || "";
|
||||||
const redirectURI = process.env.KEYCLOAK_REDIRECT_URI || "http://localhost:3000/admin/callback";
|
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 keycloak = new KeyCloak(realmURL, clientId, clientSecret, redirectURI);
|
||||||
|
|
||||||
const AdminLayout = ({ children }: elements.Children) => (
|
const AdminLayout = ({ children }: elements.Children) => (
|
||||||
@@ -38,6 +31,47 @@ const AdminLayout = ({ children }: elements.Children) => (
|
|||||||
<div class="container mx-auto p-4">
|
<div class="container mx-auto p-4">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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>
|
</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="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-2xl font-bold">Experience</h2>
|
<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>
|
<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>
|
||||||
<div id="experience-list" class="space-y-6">
|
<div id="experience-list" class="">
|
||||||
{experience.map((exp: any) => (
|
{experience.map((exp: any) => (
|
||||||
<ExperienceForm exp={exp} />
|
<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="bg-white dark:bg-gray-800 p-6 rounded shadow">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-2xl font-bold">Education</h2>
|
<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>
|
<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>
|
||||||
<div id="education-list" class="space-y-6">
|
<div id="education-list" class="">
|
||||||
{education.map((edu: any) => (
|
{education.map((edu: any) => (
|
||||||
<EducationForm edu={edu} />
|
<EducationForm edu={edu} />
|
||||||
))}
|
))}
|
||||||
@@ -217,33 +249,32 @@ export const adminRoutes = new Elysia()
|
|||||||
return Response.redirect("/admin/dashboard");
|
return Response.redirect("/admin/dashboard");
|
||||||
})
|
})
|
||||||
|
|
||||||
// HTMX Create Experience
|
|
||||||
.post("/admin/experience/new", ({ set }) => {
|
.post("/admin/experience/new", ({ set }) => {
|
||||||
const id = createExperience(); // Returns number
|
const id = createExperience();
|
||||||
const newItem = getAdminExperienceById(Number(id));
|
const newItem = getAdminExperienceById(Number(id));
|
||||||
if (!newItem) return "";
|
if (!newItem) return "";
|
||||||
return <ExperienceForm exp={newItem} />;
|
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 }) => {
|
.post("/admin/experience/:id", ({ params, body, set }) => {
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
updateExperience(id, body);
|
updateExperience(id, body);
|
||||||
|
|
||||||
// Fetch fresh data to populate form
|
|
||||||
const updatedExp = getAdminExperienceById(id);
|
const updatedExp = getAdminExperienceById(id);
|
||||||
if (!updatedExp) return "";
|
if (!updatedExp) return "";
|
||||||
|
|
||||||
return <ExperienceForm exp={updatedExp} />;
|
return <ExperienceForm exp={updatedExp} />;
|
||||||
})
|
})
|
||||||
|
|
||||||
// HTMX Delete Experience
|
|
||||||
.post("/admin/experience/:id/delete", ({ params, set }) => {
|
.post("/admin/experience/:id/delete", ({ params, set }) => {
|
||||||
deleteExperience(Number(params.id));
|
deleteExperience(Number(params.id));
|
||||||
return "";
|
return "";
|
||||||
})
|
})
|
||||||
|
|
||||||
// HTMX Create Education
|
|
||||||
.post("/admin/education/new", ({ set }) => {
|
.post("/admin/education/new", ({ set }) => {
|
||||||
const id = createEducation();
|
const id = createEducation();
|
||||||
const newItem = getAdminEducationById(Number(id));
|
const newItem = getAdminEducationById(Number(id));
|
||||||
@@ -251,18 +282,20 @@ export const adminRoutes = new Elysia()
|
|||||||
return <EducationForm edu={newItem} />;
|
return <EducationForm edu={newItem} />;
|
||||||
})
|
})
|
||||||
|
|
||||||
// HTMX Update Education
|
.post("/admin/education/reorder", ({ body }) => {
|
||||||
|
const { ids } = body as { ids: string[] };
|
||||||
|
updateEducationOrder(ids.map(Number));
|
||||||
|
return "OK";
|
||||||
|
})
|
||||||
|
|
||||||
.post("/admin/education/:id", ({ params, body, set }) => {
|
.post("/admin/education/:id", ({ params, body, set }) => {
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
updateEducation(id, body);
|
updateEducation(id, body);
|
||||||
|
|
||||||
const updatedEdu = getAdminEducationById(id);
|
const updatedEdu = getAdminEducationById(id);
|
||||||
if (!updatedEdu) return "";
|
if (!updatedEdu) return "";
|
||||||
|
|
||||||
return <EducationForm edu={updatedEdu} />;
|
return <EducationForm edu={updatedEdu} />;
|
||||||
})
|
})
|
||||||
|
|
||||||
// HTMX Delete Education
|
|
||||||
.post("/admin/education/:id/delete", ({ params, set }) => {
|
.post("/admin/education/:id/delete", ({ params, set }) => {
|
||||||
deleteEducation(Number(params.id));
|
deleteEducation(Number(params.id));
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
Reference in New Issue
Block a user