diff --git a/src/db/mutations.ts b/src/db/mutations.ts
new file mode 100644
index 0000000..f92320a
--- /dev/null
+++ b/src/db/mutations.ts
@@ -0,0 +1,94 @@
+import { db } from "./schema";
+
+// --- Profile ---
+export function updateProfile(id: number, data: any) {
+ // Update structural data
+ db.run(`
+ UPDATE profile
+ SET email = $email, phone = $phone, website = $website, github_url = $github, linkedin_url = $linkedin, avatar_url = $avatar
+ WHERE id = $id
+ `, {
+ $email: data.email, $phone: data.phone, $website: data.website,
+ $github: data.github_url, $linkedin: data.linkedin_url, $avatar: data.avatar_url, $id: id
+ });
+
+ // Update Translations (Upsert logic would be better, but for now we assume rows exist from seed)
+ // We'll just loop through languages.
+ for (const lang of ["en", "de"]) {
+ db.run(`
+ UPDATE profile_translations
+ SET name = $name, job_title = $title, summary = $summary, location = $loc
+ WHERE profile_id = $id AND language_code = $lang
+ `, {
+ $name: data[`name_${lang}`], $title: data[`job_title_${lang}`],
+ $summary: data[`summary_${lang}`], $loc: data[`location_${lang}`],
+ $id: id, $lang: lang
+ });
+ }
+}
+
+// --- Experience ---
+export function deleteExperience(id: number) {
+ db.run("DELETE FROM experience WHERE id = $id", { $id: id });
+}
+
+export function createExperience() {
+ const res = db.run(`
+ INSERT INTO experience (start_date, display_order) VALUES ('2024-01', 0)
+ `);
+ // Initialize translations
+ const id = (res as any).lastInsertRowid;
+ db.run(`INSERT INTO experience_translations (experience_id, language_code, company_name, role) VALUES (?, 'en', 'New Company', 'New Role')`, [id]);
+ db.run(`INSERT INTO experience_translations (experience_id, language_code, company_name, role) VALUES (?, 'de', 'Neue Firma', 'Neuer Job')`, [id]);
+ return id;
+}
+
+export function updateExperience(id: number, data: any) {
+ db.run(`
+ UPDATE experience SET start_date = $start, end_date = $end, company_url = $url, display_order = $order
+ WHERE id = $id
+ `, { $start: data.start_date, $end: data.end_date || null, $url: data.company_url, $order: data.display_order || 0, $id: id });
+
+ for (const lang of ["en", "de"]) {
+ db.run(`
+ UPDATE experience_translations
+ SET company_name = $comp, role = $role, description = $desc, location = $loc
+ WHERE experience_id = $id AND language_code = $lang
+ `, {
+ $comp: data[`company_name_${lang}`], $role: data[`role_${lang}`],
+ $desc: data[`description_${lang}`], $loc: data[`location_${lang}`],
+ $id: id, $lang: lang
+ });
+ }
+}
+
+// --- Education ---
+export function deleteEducation(id: number) {
+ db.run("DELETE FROM education WHERE id = $id", { $id: id });
+}
+
+export function createEducation() {
+ const res = db.run(`INSERT INTO education (start_date, display_order) VALUES ('2024-01', 0)`);
+ const id = (res as any).lastInsertRowid;
+ db.run(`INSERT INTO education_translations (education_id, language_code, institution, degree) VALUES (?, 'en', 'New Institution', 'Degree')`, [id]);
+ db.run(`INSERT INTO education_translations (education_id, language_code, institution, degree) VALUES (?, 'de', 'Neue Institution', 'Abschluss')`, [id]);
+ return id;
+}
+
+export function updateEducation(id: number, data: any) {
+ db.run(`
+ UPDATE education SET start_date = $start, end_date = $end, institution_url = $url, display_order = $order
+ WHERE id = $id
+ `, { $start: data.start_date, $end: data.end_date || null, $url: data.institution_url, $order: data.display_order || 0, $id: id });
+
+ for (const lang of ["en", "de"]) {
+ db.run(`
+ UPDATE education_translations
+ SET institution = $inst, degree = $deg, description = $desc
+ WHERE education_id = $id AND language_code = $lang
+ `, {
+ $inst: data[`institution_${lang}`], $deg: data[`degree_${lang}`], $desc: data[`description_${lang}`],
+ $id: id, $lang: lang
+ });
+ }
+}
diff --git a/src/db/queries.ts b/src/db/queries.ts
index 7544a56..ddeda87 100644
--- a/src/db/queries.ts
+++ b/src/db/queries.ts
@@ -29,6 +29,19 @@ interface Experience extends ExperienceTranslation {
company_url: string | null;
}
+interface EducationTranslation {
+ institution: string;
+ degree: string;
+ description: string | null;
+}
+
+interface Education extends EducationTranslation {
+ start_date: string;
+ end_date: string | null;
+ institution_url: string | null;
+ display_order: number;
+}
+
interface SkillTranslation {
name: string;
category_display: string | null;
@@ -63,6 +76,18 @@ export function getExperience(lang: string): Experience[] {
return experience;
}
+export function getEducation(lang: string): Education[] {
+ const education = db.query(`
+ SELECT e.start_date, e.end_date, e.institution_url, e.display_order,
+ et.institution, et.degree, et.description
+ FROM education e
+ JOIN education_translations et ON e.id = et.education_id
+ WHERE et.language_code = $lang
+ ORDER BY e.display_order ASC, e.start_date DESC
+ `).all({ $lang: lang }) as Education[];
+ return education;
+}
+
export function getSkills(lang: string): Skill[] {
const skills = db.query(`
SELECT s.category, s.icon, s.display_order,
@@ -75,44 +100,55 @@ export function getSkills(lang: string): Skill[] {
return skills;
}
-export function getEducation(lang: string): Education[] {
-
- const education = db.query(`
-
- SELECT e.start_date, e.end_date, e.institution_url, e.display_order,
-
- et.institution, et.degree, et.description
-
- FROM education e
-
- JOIN education_translations et ON e.id = et.education_id
-
- WHERE et.language_code = $lang
-
- ORDER BY e.display_order ASC, e.start_date DESC
-
- `).all({ $lang: lang }) as Education[];
-
- return education;
-
-}
-
-
-
export function getAllData(lang: string) {
-
return {
-
profile: getProfile(lang),
-
experience: getExperience(lang),
-
education: getEducation(lang),
-
skills: getSkills(lang),
-
};
+}
+// --- Admin Queries ---
+
+export function getAdminProfile() {
+ const profile = db.query(`SELECT * FROM profile WHERE id = 1`).get() as any;
+ const translations = db.query(`SELECT * FROM profile_translations WHERE profile_id = 1`).all() as any[];
+
+ translations.forEach(t => {
+ profile[`name_${t.language_code}`] = t.name;
+ profile[`job_title_${t.language_code}`] = t.job_title;
+ profile[`summary_${t.language_code}`] = t.summary;
+ profile[`location_${t.language_code}`] = t.location;
+ });
+ return profile;
+}
+
+export function getAdminExperience() {
+ const exps = db.query(`SELECT * FROM experience ORDER BY display_order ASC, start_date DESC`).all() as any[];
+ return exps.map(e => {
+ const trans = db.query(`SELECT * FROM experience_translations WHERE experience_id = $id`, { $id: e.id }).all() as any[];
+ trans.forEach(t => {
+ e[`company_name_${t.language_code}`] = t.company_name;
+ e[`role_${t.language_code}`] = t.role;
+ e[`description_${t.language_code}`] = t.description;
+ e[`location_${t.language_code}`] = t.location;
+ });
+ return e;
+ });
+}
+
+export function getAdminEducation() {
+ const edus = db.query(`SELECT * FROM education ORDER BY display_order ASC, start_date DESC`).all() as any[];
+ return edus.map(e => {
+ const trans = db.query(`SELECT * FROM education_translations WHERE education_id = $id`, { $id: e.id }).all() as any[];
+ trans.forEach(t => {
+ e[`institution_${t.language_code}`] = t.institution;
+ e[`degree_${t.language_code}`] = t.degree;
+ e[`description_${t.language_code}`] = t.description;
+ });
+ return e;
+ });
}
diff --git a/src/db/seed.ts b/src/db/seed.ts
index fa26e3a..214dad8 100644
--- a/src/db/seed.ts
+++ b/src/db/seed.ts
@@ -64,7 +64,30 @@ export function seedDB() {
$desc: "Leitung des Frontend-Teams und Migration der Legacy-Codebasis zu ElysiaJS."
});
- // 4. Skills
+ // 4. Education
+ const insertEdu = db.prepare(`
+ INSERT INTO education (start_date, end_date, institution_url, display_order)
+ VALUES ($start, $end, $url, $order)
+ RETURNING id
+ `);
+
+ const edu1 = insertEdu.get({ $start: "2010-09", $end: "2014-07", $url: "https://example-university.edu", $order: 1 }) as { id: number };
+
+ const insertEduTrans = db.prepare(`
+ INSERT INTO education_translations (education_id, language_code, institution, degree, description)
+ VALUES ($eid, $code, $inst, $deg, $desc)
+ `);
+
+ insertEduTrans.run({
+ $eid: edu1.id, $code: "en", $inst: "Example University", $deg: "M.Sc. Computer Science",
+ $desc: "Focused on distributed systems and artificial intelligence."
+ });
+ insertEduTrans.run({
+ $eid: edu1.id, $code: "de", $inst: "Beispiel Universität", $deg: "M.Sc. Informatik",
+ $desc: "Schwerpunkt auf verteilten Systemen und künstlicher Intelligenz."
+ });
+
+ // 5. Skills
const insertSkill = db.prepare(`
INSERT INTO skills (category, icon, display_order) VALUES ($cat, $icon, $order) RETURNING id
`);
diff --git a/src/index.tsx b/src/index.tsx
index c0d64fc..d5fc8c8 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,51 +1,14 @@
-import { Elysia, NotFoundError } from "elysia";
+import { Elysia } from "elysia";
import { html } from "@elysiajs/html";
-import * as elements from "typed-html";
-import { BaseHtml } from "./components/BaseHtml";
-import { Layout } from "./components/Layout";
-import { HeroSection, AboutSection, ExperienceSection, EducationSection, SkillsSection } from "./components/Sections";
-import { getAllData } from "./db/queries";
+import { adminRoutes } from "./routes/admin";
+import { publicRoutes } from "./routes/public";
const app = new Elysia()
.use(html())
- .get("/", () => {
- return Response.redirect("/en"); // Default to English
- })
- .get("/:lang", ({ params, html, set }) => {
- const lang = params.lang as "en" | "de";
- if (!["en", "de"].includes(lang)) {
- throw new NotFoundError();
- }
-
- const data = getAllData(lang);
- if (!data.profile) {
- throw new NotFoundError("Profile data not found for selected language.");
- }
-
- return html(
-
-
-
-
- {/* Separate About Section using the summary */}
- {data.profile.summary && }
-
-
-
-
- {/* Only render Education section if data exists */}
- {data.education && data.education.length > 0 && (
-
- )}
-
-
-
-
-
- );
- })
+ .use(adminRoutes)
+ .use(publicRoutes)
.listen(3000);
console.log(
`Elysia is running at http://${app.server?.hostname}:${app.server?.port}`
-);
+);
\ No newline at end of file
diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx
new file mode 100644
index 0000000..b636e1e
--- /dev/null
+++ b/src/routes/admin.tsx
@@ -0,0 +1,326 @@
+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 { getAdminProfile, getAdminExperience, getAdminEducation } from "../db/queries";
+import { updateProfile, createExperience, updateExperience, deleteExperience, createEducation, updateEducation, deleteEducation } from "../db/mutations";
+
+// Initialize Keycloak (Arctic)
+// Ensure these env vars are set!
+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";
+
+console.log("--- Keycloak Config Debug ---");
+console.log("Realm URL:", realmURL);
+console.log("Client ID:", clientId);
+console.log("Redirect URI:", redirectURI);
+console.log("Client Secret Length:", clientSecret.length); // Check if secret is loaded
+console.log("-----------------------------");
+
+const keycloak = new KeyCloak(realmURL, clientId, clientSecret, redirectURI);
+
+const AdminLayout = ({ children }: elements.Children) => (
+
+);
+
+const InputGroup = ({ label, name, value, type = "text", required = false }: any) => (
+
+
+
+
+);
+
+const TextAreaGroup = ({ label, name, value }: any) => (
+
+
+
+
+);
+
+export const adminRoutes = new Elysia()
+ .use(cookie())
+ .use(html())
+ // Auth Middleware
+ .derive(({ cookie: { auth_session } }) => {
+ return {
+ isLoggedIn: !!auth_session?.value
+ };
+ })
+
+ // 1. Login Page (Redirect to Keycloak)
+ .get("/admin/login", ({ isLoggedIn, set, html, cookie: { oauth_state, oauth_code_verifier } }) => {
+ if (isLoggedIn) return Response.redirect("/admin/dashboard");
+
+ // Generate State & Verifier for PKCE flow
+ const state = generateState();
+ const codeVerifier = generateCodeVerifier();
+
+ // In a real production app with Arctic, you'd use generateState() / generateCodeVerifier() helpers if available,
+ // or just random strings. Arctic 1.x/2.x/3.x changes API slightly.
+ // Arctic 3.x Keycloak.createAuthorizationURL requires state and scopes.
+ // Note: Arctic handles the heavy lifting usually.
+
+ // IMPORTANT: Arctic's createAuthorizationURL signature:
+ // (state: string, codeVerifier: string, scopes: string[])
+
+ // We need to temporarily store state/verifier in cookies to verify later
+ oauth_state.set({
+ value: state,
+ path: "/",
+ secure: process.env.NODE_ENV === "production",
+ httpOnly: true,
+ maxAge: 600 // 10 min
+ });
+
+ oauth_code_verifier.set({
+ value: codeVerifier,
+ path: "/",
+ secure: process.env.NODE_ENV === "production",
+ httpOnly: true,
+ maxAge: 600 // 10 min
+ });
+
+ try {
+ const url = keycloak.createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
+
+ return html(
+
+
+
+ );
+ } 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 {
+ // Exchange code for tokens
+ const tokens = await keycloak.validateAuthorizationCode(code, storedVerifier);
+
+ // Ideally, you decode the tokens.idToken to get user info and check roles.
+ // For now, if we got tokens, we assume the user authenticated successfully with Keycloak.
+ // In a real app, you MUST check `tokens.idToken` claims (e.g. `sub`, `email`).
+
+ // Set a simple session cookie
+ auth_session.set({
+ value: tokens.accessToken(), // Or just "true" if you don't need the token
+ httpOnly: true,
+ path: "/",
+ secure: process.env.NODE_ENV === "production",
+ maxAge: 86400 // 1 day
+ });
+
+ // Cleanup
+ 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();
+ // Optional: Redirect to Keycloak logout endpoint as well
+ return Response.redirect("/admin/login");
+ })
+
+ // Protected Routes Guard
+ .onBeforeHandle(({ isLoggedIn, set }) => {
+ if (!isLoggedIn) return Response.redirect("/admin/login");
+ })
+
+ // ... Dashboard and POST handlers remain the same ...
+ .get("/admin/dashboard", ({ html }) => {
+ const profile = getAdminProfile();
+ const experience = getAdminExperience();
+ const education = getAdminEducation();
+
+ return html(
+
+
+ Dashboard
+
+ {/* Profile Section */}
+
+
+ {/* Experience Section */}
+
+
+
Experience
+
+
+
+ {experience.map((exp: any) => (
+
+ ))}
+
+
+
+ {/* Education Section */}
+
+
+
Education
+
+
+
+ {education.map((edu: any) => (
+
+ ))}
+
+
+
+
+
+ );
+ })
+ // Handlers
+ .post("/admin/profile", ({ body, set }) => {
+ updateProfile(1, body);
+ return Response.redirect("/admin/dashboard");
+ })
+ .post("/admin/experience/new", ({ set }) => {
+ createExperience();
+ return Response.redirect("/admin/dashboard");
+ })
+ .post("/admin/experience/:id", ({ params, body, set }) => {
+ updateExperience(Number(params.id), body);
+ return Response.redirect("/admin/dashboard");
+ })
+ .post("/admin/experience/:id/delete", ({ params, set }) => {
+ deleteExperience(Number(params.id));
+ return Response.redirect("/admin/dashboard");
+ })
+ .post("/admin/education/new", ({ set }) => {
+ createEducation();
+ return Response.redirect("/admin/dashboard");
+ })
+ .post("/admin/education/:id", ({ params, body, set }) => {
+ updateEducation(Number(params.id), body);
+ return Response.redirect("/admin/dashboard");
+ })
+ .post("/admin/education/:id/delete", ({ params, set }) => {
+ deleteEducation(Number(params.id));
+ return Response.redirect("/admin/dashboard");
+ });
\ No newline at end of file
diff --git a/src/routes/public.tsx b/src/routes/public.tsx
new file mode 100644
index 0000000..8041f15
--- /dev/null
+++ b/src/routes/public.tsx
@@ -0,0 +1,50 @@
+import { Elysia, NotFoundError } from "elysia";
+import { html } from "@elysiajs/html";
+import * as elements from "typed-html";
+import { BaseHtml } from "../components/BaseHtml";
+import { Layout } from "../components/Layout";
+import { HeroSection, AboutSection, ExperienceSection, EducationSection, SkillsSection } from "../components/Sections";
+import { getAllData } from "../db/queries";
+
+export const publicRoutes = new Elysia()
+ .use(html())
+ .get("/", () => {
+ return Response.redirect("/en"); // Default to English
+ })
+ .get("/:lang", ({ params, html, set }) => {
+ const lang = params.lang as "en" | "de";
+ if (!["en", "de"].includes(lang)) {
+ // Check if it's actually a file request or something else before throwing 404 strictly
+ // but for this simple app, strict checking is fine.
+ // Actually, if I visit /favicon.ico, it might hit this.
+ // Let's just return NotFoundError and let Elysia handle it.
+ throw new NotFoundError();
+ }
+
+ const data = getAllData(lang);
+ if (!data.profile) {
+ throw new NotFoundError("Profile data not found for selected language.");
+ }
+
+ return html(
+
+
+
+
+ {/* Separate About Section using the summary */}
+ {data.profile.summary && }
+
+
+
+
+ {/* Only render Education section if data exists */}
+ {(data.education && data.education.length > 0) ? (
+
+ ) : ""}
+
+
+
+
+
+ );
+ });
diff --git a/tailwind.config.js b/tailwind.config.js
index 6a4a71f..d722a9b 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -5,7 +5,17 @@ export default {
],
darkMode: 'class', // Enables dark mode based on 'dark' class in HTML
theme: {
- extend: {},
+ extend: {
+ keyframes: {
+ fadeIn: {
+ '0%': { opacity: '0', transform: 'translateY(10px)' },
+ '100%': { opacity: '1', transform: 'translateY(0)' },
+ },
+ },
+ animation: {
+ 'fade-in': 'fadeIn 0.8s ease-out forwards',
+ },
+ },
},
plugins: [],
}
\ No newline at end of file
diff --git a/tests/db.test.ts b/tests/db.test.ts
new file mode 100644
index 0000000..4811cd2
--- /dev/null
+++ b/tests/db.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it, beforeAll } from "bun:test";
+import { getAllData, getProfile, getExperience } from "../src/db/queries";
+import { initDB, db } from "../src/db/schema";
+import { seedDB } from "../src/db/seed";
+
+// Use an in-memory DB or a separate test DB file for isolation
+// For simplicity in this specific setup, we are testing against the existing file
+// or re-seeding it. In a CI env, we'd use ':memory:'.
+// Let's ensure the DB is seeded before testing.
+beforeAll(() => {
+ seedDB();
+});
+
+describe("Database Queries", () => {
+ it("should fetch English profile data correctly", () => {
+ const profile = getProfile("en");
+ expect(profile).not.toBeNull();
+ expect(profile?.name).toBe("John Doe");
+ expect(profile?.job_title).toBe("Full Stack Developer");
+ });
+
+ it("should fetch German profile data correctly", () => {
+ const profile = getProfile("de");
+ expect(profile).not.toBeNull();
+ expect(profile?.name).toBe("John Doe");
+ expect(profile?.job_title).toBe("Full Stack Entwickler"); // Translation check
+ });
+
+ it("should fetch experience sorted by order", () => {
+ const experience = getExperience("en");
+ expect(Array.isArray(experience)).toBe(true);
+ if (experience.length > 0) {
+ expect(experience[0]).toHaveProperty("company_name");
+ expect(experience[0]).toHaveProperty("role");
+ }
+ });
+
+ it("getAllData should return all sections", () => {
+ const data = getAllData("en");
+ expect(data).toHaveProperty("profile");
+ expect(data).toHaveProperty("experience");
+ expect(data).toHaveProperty("education");
+ expect(data).toHaveProperty("skills");
+ });
+
+ it("should return null/empty for non-existent language", () => {
+ // Our query logic returns null if the join fails usually,
+ // but let's see how the current implementation behaves with invalid input.
+ const profile = getProfile("fr");
+ // Depending on implementation, this might be null or undefined
+ expect(profile).toBeNull();
+ });
+});