diff --git a/.env b/.env new file mode 100644 index 0000000..705ab4d --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +# Keycloak Configuration +KEYCLOAK_REALM_URL="http://localhost:8080/realms/myrealm" +KEYCLOAK_CLIENT_ID="cv-app" +KEYCLOAK_CLIENT_SECRET="urBMT3daJQQvPc05RvePnOlaK6MdQdSJ" +KEYCLOAK_REDIRECT_URI="http://localhost:3000/admin/callback" diff --git a/bun.lock b/bun.lock index 07492f2..4fd564f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,10 @@ "": { "name": "app", "dependencies": { + "@elysiajs/cookie": "^0.8.0", "@elysiajs/html": "^1.4.0", "@kitajs/ts-html-plugin": "^4.1.3", + "arctic": "^3.7.0", "autoprefixer": "^10.4.22", "elysia": "^1.4.16", "postcss": "^8.5.6", @@ -21,18 +23,34 @@ "packages": { "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], + "@elysiajs/cookie": ["@elysiajs/cookie@0.8.0", "", { "dependencies": { "@types/cookie": "^0.5.1", "@types/cookie-signature": "^1.1.0", "cookie": "^0.5.0", "cookie-signature": "^1.2.1" }, "peerDependencies": { "elysia": ">= 0.8.0" } }, "sha512-CUtDwdYEoN0BcQ3SgZrB4x5nrbM4ih0sMhMuKKdMlEvqLtmRQDfq9KBCrMJW6L/Q0tPH0JLRqwjEbVb6rJufCw=="], + "@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="], "@kitajs/html": ["@kitajs/html@4.2.11", "", { "dependencies": { "csstype": "^3.1.3" } }, "sha512-gOe+zzCZKN2fPT1FUK32mHsr21ILcAOUUux/yDqQthInW8egN8RuxVp+zP3KhwWETVACkurBiKV9RWuNw+ceiw=="], "@kitajs/ts-html-plugin": ["@kitajs/ts-html-plugin@4.1.3", "", { "dependencies": { "chalk": "^5.6.2", "tslib": "^2.8.1", "yargs": "^18.0.0" }, "peerDependencies": { "@kitajs/html": "^4.2.10", "typescript": "^5.6.2" }, "bin": { "ts-html-plugin": "dist/cli.js", "xss-scan": "dist/cli.js" } }, "sha512-NlYrID5yMxfRKiO1eiiSC4MWveKe0ffoCJOZm4idNOqwimmLXr0g1NmvCcquOU2XLRrgzynxZqw6rhwR5CY5Nw=="], + "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], + + "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], + + "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="], + + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + + "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/cookie": ["@types/cookie@0.5.4", "", {}, "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA=="], + + "@types/cookie-signature": ["@types/cookie-signature@1.1.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-2OhrZV2LVnUAXklUFwuYUTokalh/dUb8rqt70OW6ByMSxYpauPZ+kfNLknX3aJyjY5iu8i3cUyoLZP9Fn37tTg=="], + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], "@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="], @@ -41,6 +59,8 @@ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "arctic": ["arctic@3.7.0", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw=="], + "autoprefixer": ["autoprefixer@10.4.22", "", { "dependencies": { "browserslist": "^4.27.0", "caniuse-lite": "^1.0.30001754", "fraction.js": "^5.3.4", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.8.30", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA=="], @@ -55,7 +75,9 @@ "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + "cookie": ["cookie@0.5.0", "", {}, "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -132,5 +154,9 @@ "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + + "elysia/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], } } diff --git a/check_arctic.js b/check_arctic.js new file mode 100644 index 0000000..b1fbf52 --- /dev/null +++ b/check_arctic.js @@ -0,0 +1,2 @@ +const arctic = require('arctic'); +console.log(Object.keys(arctic)); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..56c85f5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + postgres: + image: postgres:15 + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: password + networks: + - keycloak_network + + keycloak: + image: quay.io/keycloak/keycloak:23.0 + command: start-dev + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: password + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + ports: + - "8080:8080" + depends_on: + - postgres + networks: + - keycloak_network + +volumes: + postgres_data: + +networks: + keycloak_network: + driver: bridge diff --git a/package.json b/package.json index 6fcad5a..f38d4f9 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "dev": "bun run --watch src/index.ts" }, "dependencies": { + "@elysiajs/cookie": "^0.8.0", "@elysiajs/html": "^1.4.0", "@kitajs/ts-html-plugin": "^4.1.3", + "arctic": "^3.7.0", "autoprefixer": "^10.4.22", "elysia": "^1.4.16", "postcss": "^8.5.6", diff --git a/src/components/Sections.tsx b/src/components/Sections.tsx index af25a05..83c4e3f 100644 --- a/src/components/Sections.tsx +++ b/src/components/Sections.tsx @@ -54,8 +54,7 @@ export const ExperienceSection = ({ experience }: any) => (
{experience.map((exp: any) => (
- {/* Hover highlight on line - optional subtle effect */} -
+
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) => ( +
+ +
+ {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( + +
+
+

Admin Access

+ + Login with Keycloak + +
+
+
+ ); + } 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 */} +
+

Profile

+
+
+ + + + + + +
+ +
+
+

English

+ + + + +
+
+

German

+ + + + +
+
+ +
+
+ + {/* Experience Section */} +
+
+

Experience

+
+ +
+
+
+ {experience.map((exp: any) => ( +
+
+ + + +
+
+
+
ENGLISH
+ + + +
+
+
GERMAN
+ + + +
+
+
+ + +
+
+ ))} +
+
+ + {/* Education Section */} +
+
+

Education

+
+ +
+
+
+ {education.map((edu: any) => ( +
+
+ + + +
+
+
+
ENGLISH
+ + + +
+
+
GERMAN
+ + + +
+
+
+ + +
+
+ ))} +
+
+ +
+
+ ); + }) + // 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(); + }); +});