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.
125 lines
8.2 KiB
TypeScript
125 lines
8.2 KiB
TypeScript
import * as elements from "typed-html";
|
|
|
|
export const InputGroup = ({ label, name, value, type = "text", required = false }: any) => (
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-bold mb-1 text-gray-700 dark:text-gray-300">{label}</label>
|
|
<input type={type} name={name} value={value || ""} required={required}
|
|
class="w-full p-2 border rounded dark:bg-gray-800 dark:border-gray-600" />
|
|
</div>
|
|
);
|
|
|
|
export const TextAreaGroup = ({ label, name, value }: any) => (
|
|
<div class="mb-4">
|
|
<label class="block text-sm font-bold mb-1 text-gray-700 dark:text-gray-300">{label}</label>
|
|
<textarea name={name} rows="4" class="w-full p-2 border rounded dark:bg-gray-800 dark:border-gray-600">{value || ""}</textarea>
|
|
</div>
|
|
);
|
|
|
|
export const ExperienceForm = ({ exp }: any) => (
|
|
<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>
|
|
|
|
{/* 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>
|
|
);
|
|
|
|
export const EducationForm = ({ edu }: any) => (
|
|
<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>
|
|
|
|
{/* 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 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>
|
|
</form>
|
|
); |