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).
353 lines
15 KiB
TypeScript
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 "";
|
|
}); |