feat(db): Implement schema migrations and add Projects section

Introduces a custom migration system for SQLite, allowing incremental and safe schema evolution.
Adds a new 'Projects' section to the CV, including database tables, public UI, and full management
in the admin dashboard with live editing, drag-and-drop reordering, and collapsible forms.

Updates:
-  and  for schema management.
-  with  script.
-  to use migrations.
-  to rely on migrations.
-  and  for new project data operations.
-  and  for Projects UI.
-  and  to integrate the Projects section.

Also updates:
-  to automatically import Keycloak realm on startup.
-  for the Elysia app build.
-  with refined print styles (omitting socials and about).
This commit is contained in:
Tuan-Dat Tran
2025-11-22 11:20:03 +01:00
parent be0be3bd00
commit 3de8f6a971
18 changed files with 684 additions and 136 deletions

View File

@@ -4,9 +4,9 @@ import { cookie } from "@elysiajs/cookie";
import { KeyCloak, generateState, generateCodeVerifier } from "arctic";
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, updateExperienceOrder, updateEducationOrder } from "../db/mutations";
import { InputGroup, TextAreaGroup, ExperienceForm, EducationForm, ProjectForm } from "../components/AdminForms";
import { getAdminProfile, getAdminExperience, getAdminEducation, getAdminProjects, getAdminExperienceById, getAdminEducationById, getAdminProjectById } from "../db/queries";
import { updateProfile, createExperience, updateExperience, deleteExperience, createEducation, updateEducation, deleteEducation, updateExperienceOrder, updateEducationOrder, createProject, updateProject, deleteProject, updateProjectOrder } from "../db/mutations";
// Initialize Keycloak (Arctic)
const realmURL = process.env.KEYCLOAK_REALM_URL || "";
@@ -44,14 +44,20 @@ const AdminLayout = ({ children }: elements.Children) => (
if (el._sortable) return;
console.log('Initializing Sortable on:', id);
el._sortable = new Sortable(el, {
group: id,
draggable: 'form',
handle: '.drag-handle',
animation: 150,
ghostClass: 'bg-blue-100',
onStart: function(evt) {
console.log('Drag started', evt);
},
onEnd: function (evt) {
console.log('Drag ended', evt);
const ids = Array.from(el.querySelectorAll('form')).map(f => f.getAttribute('data-id'));
console.log('New order:', ids);
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -64,11 +70,13 @@ const AdminLayout = ({ children }: elements.Children) => (
document.addEventListener('DOMContentLoaded', () => {
initSortable('experience-list', '/admin/experience/reorder');
initSortable('education-list', '/admin/education/reorder');
initSortable('project-list', '/admin/project/reorder');
});
document.body.addEventListener('htmx:afterSwap', (evt) => {
initSortable('experience-list', '/admin/experience/reorder');
initSortable('education-list', '/admin/education/reorder');
initSortable('project-list', '/admin/project/reorder');
});
`}
</script>
@@ -173,6 +181,7 @@ export const adminRoutes = new Elysia()
const profile = getAdminProfile();
const experience = getAdminExperience();
const education = getAdminEducation();
const projects = getAdminProjects();
return html(
<BaseHtml>
@@ -212,6 +221,19 @@ export const adminRoutes = new Elysia()
</form>
</div>
{/* Projects Section */}
<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">Projects</h2>
<button hx-post="/admin/project/new" hx-target="#project-list" hx-swap="beforeend" class="bg-orange-500 text-white px-3 py-1 rounded text-sm hover:bg-orange-600">+ Add Project</button>
</div>
<div id="project-list" class="">
{projects.map((proj: any) => (
<ProjectForm proj={proj} />
))}
</div>
</div>
{/* Experience Section */}
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
<div class="flex justify-between items-center mb-4">
@@ -249,6 +271,34 @@ export const adminRoutes = new Elysia()
return Response.redirect("/admin/dashboard");
})
// Projects
.post("/admin/project/new", ({ set }) => {
const id = createProject();
const newItem = getAdminProjectById(Number(id));
if (!newItem) return "";
return <ProjectForm proj={newItem} />;
})
.post("/admin/project/reorder", ({ body }) => {
const { ids } = body as { ids: string[] };
updateProjectOrder(ids.map(Number));
return "OK";
})
.post("/admin/project/:id", ({ params, body, set }) => {
const id = Number(params.id);
updateProject(id, body);
const updatedProj = getAdminProjectById(id);
if (!updatedProj) return "";
return <ProjectForm proj={updatedProj} />;
})
.post("/admin/project/:id/delete", ({ params, set }) => {
deleteProject(Number(params.id));
return "";
})
// Experience
.post("/admin/experience/new", ({ set }) => {
const id = createExperience();
const newItem = getAdminExperienceById(Number(id));
@@ -275,6 +325,7 @@ export const adminRoutes = new Elysia()
return "";
})
// Education
.post("/admin/education/new", ({ set }) => {
const id = createEducation();
const newItem = getAdminEducationById(Number(id));
@@ -299,4 +350,4 @@ export const adminRoutes = new Elysia()
.post("/admin/education/:id/delete", ({ params, set }) => {
deleteEducation(Number(params.id));
return "";
});
});