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