Files
elysiacv/src/routes/admin.tsx
Tuan-Dat Tran 3de8f6a971 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).
2025-11-22 11:20:03 +01:00

353 lines
15 KiB
TypeScript

import { Elysia, t } from "elysia";
import { html } from "@elysiajs/html";
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, 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 || "";
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";
const keycloak = new KeyCloak(realmURL, clientId, clientSecret, redirectURI);
const AdminLayout = ({ children }: elements.Children) => (
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white">
<nav class="bg-white dark:bg-gray-800 shadow p-4 mb-8">
<div class="container mx-auto flex justify-between items-center">
<div class="font-bold text-xl">CV Admin</div>
<div class="flex gap-4">
<a href="/admin/dashboard" class="hover:text-blue-500">Dashboard</a>
<a href="/" target="_blank" class="hover:text-blue-500">View Site</a>
<a href="/admin/logout" class="text-red-500">Logout</a>
</div>
</div>
</nav>
<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;
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' },
body: JSON.stringify({ ids: ids })
});
}
});
}
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>
</div>
);
export const adminRoutes = new Elysia()
.use(cookie())
.use(html())
// Auth Middleware
.derive(({ cookie: { auth_session } }) => {
return {
isLoggedIn: !!auth_session?.value
};
})
// 1. Login Page
.get("/admin/login", ({ isLoggedIn, set, html, cookie: { oauth_state, oauth_code_verifier } }) => {
if (isLoggedIn) return Response.redirect("/admin/dashboard");
const state = generateState();
const codeVerifier = generateCodeVerifier();
oauth_state.set({
value: state,
path: "/",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 600
});
oauth_code_verifier.set({
value: codeVerifier,
path: "/",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 600
});
try {
const url = keycloak.createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
return html(
<BaseHtml>
<div class="flex h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
<div class="bg-white dark:bg-gray-800 p-8 rounded shadow-md w-96 text-center">
<h1 class="text-2xl font-bold mb-6 dark:text-white">Admin Access</h1>
<a href={url.toString()} class="block w-full bg-blue-600 text-white p-3 rounded font-bold hover:bg-blue-700 transition-colors">
Login with Keycloak
</a>
</div>
</div>
</BaseHtml>
);
} catch (e) {
return "Error generating Keycloak URL. Check .env configuration.";
}
})
// 2. Callback Handler
.get("/admin/callback", async ({ query, cookie: { oauth_state, oauth_code_verifier, auth_session }, set }) => {
const code = query.code as string;
const state = query.state as string;
const storedState = oauth_state.value;
const storedVerifier = oauth_code_verifier.value;
if (!code || !state || !storedState || !storedVerifier || state !== storedState) {
return new Response("Invalid State or Code", { status: 400 });
}
try {
const tokens = await keycloak.validateAuthorizationCode(code, storedVerifier);
auth_session.set({
value: tokens.accessToken(),
httpOnly: true,
path: "/",
secure: process.env.NODE_ENV === "production",
maxAge: 86400 // 1 day
});
oauth_state.remove();
oauth_code_verifier.remove();
return Response.redirect("/admin/dashboard");
} catch (e: any) {
console.error("Keycloak Error:", e);
return new Response(`Authentication failed: ${e.message}\n\nStack: ${e.stack}`, { status: 500 });
}
})
.get("/admin/logout", ({ cookie: { auth_session }, set }) => {
auth_session.remove();
return Response.redirect("/admin/login");
})
// Protected Routes Guard
.onBeforeHandle(({ isLoggedIn, set }) => {
if (!isLoggedIn) return Response.redirect("/admin/login");
})
// Dashboard
.get("/admin/dashboard", ({ html }) => {
const profile = getAdminProfile();
const experience = getAdminExperience();
const education = getAdminEducation();
const projects = getAdminProjects();
return html(
<BaseHtml>
<AdminLayout>
<h1 class="text-3xl font-bold mb-6">Dashboard</h1>
{/* Profile Section */}
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
<h2 class="text-2xl font-bold mb-4">Profile</h2>
<form action="/admin/profile" method="POST">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputGroup label="Email" name="email" value={profile.email} />
<InputGroup label="Phone" name="phone" value={profile.phone} />
<InputGroup label="Website" name="website" value={profile.website} />
<InputGroup label="Avatar URL" name="avatar_url" value={profile.avatar_url} />
<InputGroup label="Github" name="github_url" value={profile.github_url} />
<InputGroup label="LinkedIn" name="linkedin_url" value={profile.linkedin_url} />
</div>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6 border-t pt-4 dark:border-gray-700">
<div>
<h3 class="font-bold text-lg mb-2 text-blue-600">English</h3>
<InputGroup label="Name" name="name_en" value={profile.name_en} />
<InputGroup label="Job Title" name="job_title_en" value={profile.job_title_en} />
<TextAreaGroup label="Summary" name="summary_en" value={profile.summary_en} />
<InputGroup label="Location" name="location_en" value={profile.location_en} />
</div>
<div>
<h3 class="font-bold text-lg mb-2 text-blue-600">German</h3>
<InputGroup label="Name" name="name_de" value={profile.name_de} />
<InputGroup label="Job Title" name="job_title_de" value={profile.job_title_de} />
<TextAreaGroup label="Summary" name="summary_de" value={profile.summary_de} />
<InputGroup label="Location" name="location_de" value={profile.location_de} />
</div>
</div>
<button class="mt-4 bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">Save Profile</button>
</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">
<h2 class="text-2xl font-bold">Experience</h2>
<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="">
{experience.map((exp: any) => (
<ExperienceForm exp={exp} />
))}
</div>
</div>
{/* Education Section */}
<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>
<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="">
{education.map((edu: any) => (
<EducationForm edu={edu} />
))}
</div>
</div>
</AdminLayout>
</BaseHtml>
);
})
// POST Handlers
.post("/admin/profile", ({ body, set }) => {
updateProfile(1, body);
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));
if (!newItem) return "";
return <ExperienceForm exp={newItem} />;
})
.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);
const updatedExp = getAdminExperienceById(id);
if (!updatedExp) return "";
return <ExperienceForm exp={updatedExp} />;
})
.post("/admin/experience/:id/delete", ({ params, set }) => {
deleteExperience(Number(params.id));
return "";
})
// 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";
})
.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} />;
})
.post("/admin/education/:id/delete", ({ params, set }) => {
deleteEducation(Number(params.id));
return "";
});