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:
@@ -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 "";
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user