Compare commits

...

3 Commits

Author SHA1 Message Date
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
Tuan-Dat Tran
be0be3bd00 feat: Implement drag-and-drop reordering, collapsible forms, and UI refinements
Introduces drag-and-drop functionality for Experience and Education entries in the admin dashboard
using SortableJS, along with collapsible forms powered by Alpine.js. Ensures live updates via HTMX.

Refines both public site and admin dashboard UI:
- Public site: Role/Degree are more prominent, Company/Institution highlight on hover.
- Admin dashboard: Headers display Role/Degree as primary and include date ranges.

Addresses backend needs by re-introducing 'display_order' to the database schema and
updating queries and mutations for proper reordering.

Fixes:
- Resolved 'invalid_grant' Keycloak error by correcting PKCE code verifier generation.
- Corrected database query parameter passing to fix text field clearing on form save.
- Fixed JSX parsing errors with Alpine.js attributes by using full syntax and spread operator.
- Resolved DOMTokenList whitespace error in SortableJS ghostClass.
- Fixed SortableJS initialization and drag events to ensure visual reordering.
2025-11-22 00:45:40 +01:00
Tuan-Dat Tran
3c990e5ab6 feat: Implement live editing (HTMX) and refine UI hierarchy
Integrates HTMX for real-time updates of Experience and Education forms in the admin dashboard,
eliminating full page reloads on save and enabling instant addition of new entries.
Adjusted the visual prominence of 'Role' in Experience and 'Degree' in Education sections.

Fixes:
- Corrected database query parameter passing, resolving text field clearing issue on save.
- Enabled live 'Add New' functionality for Experience and Education entries.
2025-11-22 00:05:50 +01:00
20 changed files with 1098 additions and 291 deletions

48
Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
# Use the official Bun image
FROM oven/bun:1 as base
WORKDIR /usr/src/app
# Install dependencies into temp directory
# This will cache them and speed up future builds
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lock /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
# Install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod
COPY package.json bun.lock /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
# Copy node_modules from temp directory
# Then copy all (non-ignored) project files into the image
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
# [Optional] Tests & Build
ENV NODE_ENV=production
# RUN bun test
# RUN bun run build
# Final stage for app image
FROM base AS release
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/src src
COPY --from=prerelease /usr/src/app/package.json .
COPY --from=prerelease /usr/src/app/bun.lock .
COPY --from=prerelease /usr/src/app/tsconfig.json .
COPY --from=prerelease /usr/src/app/tailwind.config.js .
COPY --from=prerelease /usr/src/app/postcss.config.js .
# Ensure the DB directory/file exists or is volume mounted
# We will handle the DB via volume in docker-compose
# Copy the seed script so we can run it if needed
COPY --from=prerelease /usr/src/app/src/db/seed.ts src/db/seed.ts
COPY --from=prerelease /usr/src/app/src/db/schema.ts src/db/schema.ts
# Expose port
EXPOSE 3000/tcp
# Start the server
ENTRYPOINT [ "bun", "run", "src/index.tsx" ]

85
GEMINI.md Normal file
View File

@@ -0,0 +1,85 @@
# Project Context: ElysiaJS CV Website
## Project Overview
This project is a high-performance, server-side rendered (SSR) CV/Portfolio website built on the **Bun** runtime using **ElysiaJS**. It features a custom Content Management System (CMS) for managing multi-language content (English/German) and uses **SQLite** for data persistence. Authentication for the CMS is handled via **Keycloak** (OpenID Connect) using the **Arctic** library.
## Key Technologies
* **Runtime:** [Bun](https://bun.sh/)
* **Web Framework:** [ElysiaJS](https://elysiajs.com/)
* **Templating:** JSX via `@kitajs/ts-html-plugin` / `typed-html`
* **Database:** `bun:sqlite` (Native SQLite driver)
* **Authentication:** [Arctic](https://arctic.js.org/) (OIDC Client) + [Keycloak](https://www.keycloak.org/) (Identity Provider)
* **Styling:** [TailwindCSS](https://tailwindcss.com/)
* **Testing:** `bun:test`
## Architecture
### Directory Structure
* `src/components`: Reusable UI components (e.g., `Layout.tsx`, `Sections.tsx`).
* `src/db`: Database logic.
* `schema.ts`: Table definitions.
* `queries.ts`: Read operations (including Admin data fetchers).
* `mutations.ts`: Write operations (Admin actions).
* `seed.ts`: Initial data population script.
* `src/routes`: Route handlers.
* `public.tsx`: Public-facing CV pages (`/:lang`).
* `admin.tsx`: Protected CMS routes (`/admin/*`) and Auth flows.
* `src/index.tsx`: Main application entry point and server configuration.
* `tests`: Unit tests (currently covering DB queries).
### Database Schema
The database uses a "Translation Table" pattern to support i18n:
* **Structural Tables:** `profile`, `experience`, `education`, `skills` (contain IDs, dates, URLs, sort order).
* **Translation Tables:** `profile_translations`, `experience_translations`, etc. (contain language-specific text keyed by `language_code` and parent ID).
### Authentication Flow
1. User visits `/admin/login` -> Redirects to Keycloak Authorization URL.
2. Keycloak validates credentials -> Redirects back to `/admin/callback` with `code`.
3. Server exchanges `code` for tokens using Arctic (PKCE enabled).
4. Session cookie is set.
5. Protected routes check for valid session cookie.
## Development Workflow
### Prerequisites
* Bun installed.
* Docker installed (for Keycloak).
### Setup & Installation
1. **Install Dependencies:**
```bash
bun install
```
2. **Initialize Database:**
```bash
bun run src/db/seed.ts
```
3. **Start Keycloak:**
```bash
docker compose up -d
```
* Admin Console: `http://localhost:8080` (admin/admin)
* *Note:* Ensure a Realm `myrealm` and Client `cv-app` are configured.
### Running the Application
* **Development Server:**
```bash
bun run src/index.tsx
```
Access at `http://localhost:3000`.
### Testing
* **Run Unit Tests:**
```bash
bun test
```
## Configuration
* **Environment Variables:** Managed in `.env` (contains Keycloak secrets and URLs).
* **Tailwind:** Configured in `tailwind.config.js` (includes custom animations like `fade-in`).
* **TypeScript:** Configured in `tsconfig.json` (supports JSX).
## Conventions
* **Routing:** Always use `return Response.redirect(...)` for redirects in Elysia, not `set.redirect = ...`.
* **Styling:** Use Tailwind utility classes. Dark mode is supported via the `dark:` prefix and a toggler in `Layout.tsx`.
* **Data Access:** Separate Read (`queries.ts`) and Write (`mutations.ts`) logic.

View File

@@ -8,10 +8,12 @@
"@elysiajs/cookie": "^0.8.0", "@elysiajs/cookie": "^0.8.0",
"@elysiajs/html": "^1.4.0", "@elysiajs/html": "^1.4.0",
"@kitajs/ts-html-plugin": "^4.1.3", "@kitajs/ts-html-plugin": "^4.1.3",
"alpinejs": "^3.15.2",
"arctic": "^3.7.0", "arctic": "^3.7.0",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"elysia": "^1.4.16", "elysia": "^1.4.16",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"sortablejs": "^1.15.6",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typed-html": "^3.0.1", "typed-html": "^3.0.1",
}, },
@@ -55,6 +57,12 @@
"@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="], "@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
"@vue/reactivity": ["@vue/reactivity@3.1.5", "", { "dependencies": { "@vue/shared": "3.1.5" } }, "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg=="],
"@vue/shared": ["@vue/shared@3.1.5", "", {}, "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="],
"alpinejs": ["alpinejs@3.15.2", "", { "dependencies": { "@vue/reactivity": "~3.1.1" } }, "sha512-2kYF2aG+DTFkE6p0rHG5XmN4VEb6sO9b02aOdU4+i8QN6rL0DbRZQiypDE1gBcGO65yDcqMz5KKYUYgMUxgNkw=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -123,6 +131,8 @@
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"sortablejs": ["sortablejs@1.15.6", "", {}, "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],

View File

@@ -14,7 +14,9 @@ services:
keycloak: keycloak:
image: quay.io/keycloak/keycloak:23.0 image: quay.io/keycloak/keycloak:23.0
command: start-dev command: start-dev --import-realm
volumes:
- ./realm-export.json:/opt/keycloak/data/import/realm.json
environment: environment:
KC_DB: postgres KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
@@ -22,6 +24,7 @@ services:
KC_DB_PASSWORD: password KC_DB_PASSWORD: password
KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin KEYCLOAK_ADMIN_PASSWORD: admin
KC_HOSTNAME_URL: http://localhost:8080/
ports: ports:
- "8080:8080" - "8080:8080"
depends_on: depends_on:
@@ -34,4 +37,4 @@ volumes:
networks: networks:
keycloak_network: keycloak_network:
driver: bridge driver: bridge

View File

@@ -3,16 +3,19 @@
"version": "1.0.50", "version": "1.0.50",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"dev": "bun run --watch src/index.ts" "dev": "bun run --watch src/index.ts",
"db:migrate": "bun run src/db/migrator.ts"
}, },
"dependencies": { "dependencies": {
"@elysiajs/cookie": "^0.8.0", "@elysiajs/cookie": "^0.8.0",
"@elysiajs/html": "^1.4.0", "@elysiajs/html": "^1.4.0",
"@kitajs/ts-html-plugin": "^4.1.3", "@kitajs/ts-html-plugin": "^4.1.3",
"alpinejs": "^3.15.2",
"arctic": "^3.7.0", "arctic": "^3.7.0",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"elysia": "^1.4.16", "elysia": "^1.4.16",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"sortablejs": "^1.15.6",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typed-html": "^3.0.1" "typed-html": "^3.0.1"
}, },

35
realm-export.json Normal file
View File

@@ -0,0 +1,35 @@
{
"id": "myrealm",
"realm": "myrealm",
"enabled": true,
"users": [
{
"username": "user",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "password"
}
],
"realmRoles": ["admin"]
}
],
"clients": [
{
"clientId": "cv-app",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "urBMT3daJQQvPc05RvePnOlaK6MdQdSJ",
"redirectUris": [
"http://localhost:3000/admin/callback"
],
"webOrigins": [
"http://localhost:3000"
],
"publicClient": false,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true
}
]
}

View File

@@ -0,0 +1,174 @@
import * as elements from "typed-html";
export const InputGroup = ({ label, name, value, type = "text", required = false }: any) => (
<div class="mb-4">
<label class="block text-sm font-bold mb-1 text-gray-700 dark:text-gray-300">{label}</label>
<input type={type} name={name} value={value || ""} required={required}
class="w-full p-2 border rounded dark:bg-gray-800 dark:border-gray-600" />
</div>
);
export const TextAreaGroup = ({ label, name, value }: any) => (
<div class="mb-4">
<label class="block text-sm font-bold mb-1 text-gray-700 dark:text-gray-300">{label}</label>
<textarea name={name} rows="4" class="w-full p-2 border rounded dark:bg-gray-800 dark:border-gray-600">{value || ""}</textarea>
</div>
);
export const ExperienceForm = ({ exp }: any) => (
<form hx-post={`/admin/experience/${exp.id}`} hx-target="this" hx-swap="outerHTML" data-id={exp.id} class="border dark:border-gray-700 rounded mb-2 bg-white dark:bg-gray-800 shadow-sm" x-data="{ open: false }">
{/* Header / Drag Handle */}
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 cursor-pointer select-none group" x-on:click="if (!$event.target.closest('.drag-handle')) open = !open">
<div class="flex items-center gap-3">
{/* Drag Handle Icon */}
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 p-1">
<svg class="w-5 h-5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"></path></svg>
</div>
<div>
<h3 class="font-bold text-gray-800 dark:text-white">
{exp.role_en || "New Role"}
</h3>
<div class="text-sm text-gray-500">{exp.company_name_en || "New Company"}</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="text-sm text-gray-500 font-mono hidden sm:block">
{exp.start_date}{exp.start_date && ' — '}{exp.end_date || "Present"}
</div>
{/* Collapse Icon */}
<svg class="w-5 h-5 text-gray-500 transition-transform duration-200" x-bind:class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
</div>
{/* Collapsible Content */}
<div x-show="open" class="p-4 border-t dark:border-gray-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<InputGroup label="Start Date" name="start_date" value={exp.start_date} />
<InputGroup label="End Date" name="end_date" value={exp.end_date} />
{/* Display Order hidden/disabled because D&D handles it now, but kept for DB sync if needed manually */}
<input type="hidden" name="display_order" value={exp.display_order} />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
<InputGroup label="Company" name="company_name_en" value={exp.company_name_en} />
<InputGroup label="Role" name="role_en" value={exp.role_en} />
<TextAreaGroup label="Description" name="description_en" value={exp.description_en} />
</div>
<div>
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
<InputGroup label="Company" name="company_name_de" value={exp.company_name_de} />
<InputGroup label="Role" name="role_de" value={exp.role_de} />
<TextAreaGroup label="Description" name="description_de" value={exp.description_de} />
</div>
</div>
<div class="flex justify-between mt-2">
{/* Delete button: We use hx-confirm to ask first */}
<button hx-post={`/admin/experience/${exp.id}/delete`} hx-confirm="Are you sure?" hx-target="closest form" hx-swap="outerHTML" class="text-red-500 text-sm hover:underline">Delete</button>
<button type="submit" class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600 transition-transform active:scale-95">Save</button>
</div>
</div>
</form>
);
export const EducationForm = ({ edu }: any) => (
<form hx-post={`/admin/education/${edu.id}`} hx-target="this" hx-swap="outerHTML" data-id={edu.id} class="border dark:border-gray-700 rounded mb-2 bg-white dark:bg-gray-800 shadow-sm" x-data="{ open: false }">
{/* Header / Drag Handle */}
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 cursor-pointer select-none group" x-on:click="if (!$event.target.closest('.drag-handle')) open = !open">
<div class="flex items-center gap-3">
{/* Drag Handle Icon */}
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 p-1">
<svg class="w-5 h-5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"></path></svg>
</div>
<div>
<h3 class="font-bold text-gray-800 dark:text-white">
{edu.degree_en || "New Degree"}
</h3>
<div class="text-sm text-gray-500">{edu.institution_en || "New School"}</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="text-sm text-gray-500 font-mono hidden sm:block">
{edu.start_date}{edu.start_date && ' — '}{edu.end_date || "Present"}
</div>
<svg class="w-5 h-5 text-gray-500 transition-transform duration-200" x-bind:class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
</div>
{/* Collapsible Content */}
<div x-show="open" class="p-4 border-t dark:border-gray-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<InputGroup label="Start Date" name="start_date" value={edu.start_date} />
<InputGroup label="End Date" name="end_date" value={edu.end_date} />
<input type="hidden" name="display_order" value={edu.display_order} />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
<InputGroup label="Institution" name="institution_en" value={edu.institution_en} />
<InputGroup label="Degree" name="degree_en" value={edu.degree_en} />
<TextAreaGroup label="Description" name="description_en" value={edu.description_en} />
</div>
<div>
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
<InputGroup label="Institution" name="institution_de" value={edu.institution_de} />
<InputGroup label="Degree" name="degree_de" value={edu.degree_de} />
<TextAreaGroup label="Description" name="description_de" value={edu.description_de} />
</div>
</div>
<div class="flex justify-between mt-2">
<button hx-post={`/admin/education/${edu.id}/delete`} hx-confirm="Are you sure?" hx-target="closest form" hx-swap="outerHTML" class="text-red-500 text-sm hover:underline">Delete</button>
<button type="submit" class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600 transition-transform active:scale-95">Save</button>
</div>
</div>
</form>
);
export const ProjectForm = ({ proj }: any) => (
<form hx-post={`/admin/project/${proj.id}`} hx-target="this" hx-swap="outerHTML" data-id={proj.id} class="border dark:border-gray-700 rounded mb-2 bg-white dark:bg-gray-800 shadow-sm" x-data="{ open: false }">
{/* Header / Drag Handle */}
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 cursor-pointer select-none group" x-on:click="if (!$event.target.closest('.drag-handle')) open = !open">
<div class="flex items-center gap-3">
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 p-1">
<svg class="w-5 h-5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"></path></svg>
</div>
<div>
<h3 class="font-bold text-gray-800 dark:text-white">
{proj.name_en || "New Project"}
</h3>
<div class="text-sm text-gray-500 truncate max-w-[200px]">{proj.project_url || "No URL"}</div>
</div>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-gray-500 transition-transform duration-200" x-bind:class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
</div>
{/* Collapsible Content */}
<div x-show="open" class="p-4 border-t dark:border-gray-700">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<InputGroup label="Project URL" name="project_url" value={proj.project_url} />
<InputGroup label="Image URL" name="image_url" value={proj.image_url} />
</div>
<InputGroup label="Tech Stack (comma separated)" name="tech_stack" value={proj.tech_stack} />
<input type="hidden" name="display_order" value={proj.display_order} />
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<div>
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
<InputGroup label="Name" name="name_en" value={proj.name_en} />
<TextAreaGroup label="Description" name="description_en" value={proj.description_en} />
</div>
<div>
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
<InputGroup label="Name" name="name_de" value={proj.name_de} />
<TextAreaGroup label="Description" name="description_de" value={proj.description_de} />
</div>
</div>
<div class="flex justify-between mt-2">
<button hx-post={`/admin/project/${proj.id}/delete`} hx-confirm="Are you sure?" hx-target="closest form" hx-swap="outerHTML" class="text-red-500 text-sm hover:underline">Delete</button>
<button type="submit" class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600 transition-transform active:scale-95">Save</button>
</div>
</div>
</form>
);

View File

@@ -8,34 +8,184 @@ export const BaseHtml = ({ children }: elements.Children) => `
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CV Website</title> <title>CV Website</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
tailwind.config = { <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
darkMode: 'class', <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.2/dist/cdn.min.js"></script>
theme: { <script>
extend: {}, tailwind.config = {
}, darkMode: 'class',
} theme: {
// Dark mode toggle script extend: {},
const storedTheme = localStorage.getItem('theme'); },
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { }
document.documentElement.classList.add('dark'); // Dark mode toggle script
} else { const storedTheme = localStorage.getItem('theme');
document.documentElement.classList.remove('dark'); if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
} document.documentElement.classList.add('dark');
} else {
function toggleTheme() { document.documentElement.classList.remove('dark');
if (document.documentElement.classList.contains('dark')) { }
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light'); function toggleTheme() {
} else { if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.add('dark'); document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'dark'); localStorage.setItem('theme', 'light');
} } else {
} document.documentElement.classList.add('dark');
</script> localStorage.setItem('theme', 'dark');
}
}
</script>
<style>
/* SortableJS drag feedback */
.sortable-chosen {
cursor: grabbing !important;
box-shadow: 0 4px 15px rgba(0,0,0,0.2); /* Lift effect */
opacity: 0.8; /* Slight transparency */
}
/* Class for the ghost/placeholder when item is dragged */
.sortable-ghost {
background-color: theme('colors.blue.50'); /* Lighter background */
border: 1px dashed theme('colors.blue.300'); /* Dashed border */
}
</style>
<style>
@media print {
@page {
margin: 1.5cm;
size: auto;
}
/* Global Reset for Print */
body {
background-color: #fff !important;
color: #111 !important;
font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; /* Serif looks more professional in print */
font-size: 11pt;
line-height: 1.4;
}
/* Hide elements */
header, footer, nav, .admin-controls, .drag-handle, button, form[action*='/admin/'],
#hero > div.absolute, /* Hide background blob */
#hero > div.mt-8.flex.justify-center.gap-4, /* Hide social links */
#about, /* Hide About section */
.toggle-theme-btn {
display: none !important;
}
/* Layout Overrides */
.container, main, section {
padding: 0 !important;
margin: 0 !important;
max-width: 100% !important;
width: 100% !important;
box-shadow: none !important;
}
/* Hero Section - Compact */
#hero {
padding-top: 0 !important;
padding-bottom: 1rem !important;
text-align: left !important; /* Standard CV header alignment */
border-bottom: 1px solid #333;
margin-bottom: 1.5rem;
}
#hero img {
width: 80px !important;
height: 80px !important;
float: right; /* Avatar to right */
margin-left: 1rem;
border: none !important;
border-radius: 0 !important; /* Square looks sharper or keep circle */
}
#hero h1 {
font-size: 24pt !important;
margin: 0 !important;
line-height: 1.2;
}
#hero h2 {
font-size: 14pt !important;
color: #444 !important;
margin: 0.2rem 0 0.5rem 0 !important;
}
#hero .flex.justify-center {
justify-content: flex-start !important; /* Align icons left */
}
/* Sections */
h2 {
font-size: 16pt !important;
border-bottom: 1px solid #ccc;
padding-bottom: 0.2rem;
margin-top: 1.5rem !important;
margin-bottom: 1rem !important;
color: #000 !important;
display: flex;
align-items: center;
}
/* Hide the colored span/dot in headers */
h2 span { display: none !important; }
/* Experience & Education - Remove timeline line, adjust grid */
.relative.border-l-2 {
border-left: none !important;
margin-left: 0 !important;
padding-left: 0 !important;
}
.relative.group {
margin-bottom: 1.5rem !important;
break-inside: avoid; /* Don't split jobs across pages */
}
/* Flatten the grid: Date on top or left? Let's try Side-by-Side but wider */
.md\:grid-cols-\[1fr_3fr\] {
display: grid !important;
grid-template-columns: 18% 82% !important; /* Fixed width for dates */
gap: 1rem !important;
}
/* Typography refinements */
h3 {
font-size: 12pt !important;
margin: 0 !important;
color: #000 !important;
}
.text-blue-600, .text-purple-600, .group-hover\:text-blue-600 {
color: #000 !important; /* Remove colors */
}
.text-sm {
font-size: 10pt !important;
}
/* Skills - Plain list */
#skills .flex.flex-wrap {
display: block !important;
}
#skills .px-4.py-2 {
background: none !important;
border: none !important;
padding: 0 !important;
display: inline-block;
margin-right: 0.5rem;
box-shadow: none !important;
}
#skills .px-4.py-2:not(:last-child):after {
content: ", ";
}
#skills .px-4.py-2 span {
display: none; /* Hide category label if it's too cluttered, or keep it? Hiding for clean list. */
}
/* Links - Clean */
a { text-decoration: none !important; color: #000 !important; }
a[href^="http"]:after { content: ""; } /* Remove URL expansion */
/* Dark Mode override */
html.dark body { background: white !important; color: black !important; }
.dark\:bg-gray-800\/50 { background: none !important; border: none !important; }
}
</style>
</head> </head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors duration-300"> <body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors duration-300" x-data>
${children} ${children}
</body> </body></html>
</html>
`; `;

View File

@@ -54,17 +54,16 @@ export const ExperienceSection = ({ experience }: any) => (
<div class="relative border-l-2 border-gray-200 dark:border-gray-700 ml-3 pl-8 space-y-12"> <div class="relative border-l-2 border-gray-200 dark:border-gray-700 ml-3 pl-8 space-y-12">
{experience.map((exp: any) => ( {experience.map((exp: any) => (
<div class="relative group"> <div class="relative group">
<div class="grid md:grid-cols-[1fr_3fr] gap-4"> <div class="grid md:grid-cols-[1fr_3fr] gap-4">
<div class="text-sm text-gray-500 dark:text-gray-400 pt-1 font-mono"> <div class="text-sm text-gray-500 dark:text-gray-400 pt-1 font-mono">
{exp.start_date} {exp.end_date || "Present"} {exp.start_date}{exp.start_date && ' — '}{exp.end_date || "Present"}
</div> </div>
<div> <div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"> <h3 class="text-xl font-bold text-blue-600 dark:text-blue-400">
{exp.company_name} {exp.role}
</h3> </h3>
<div class="text-blue-600 dark:text-blue-400 font-medium mb-2">{exp.role}</div> <div class="text-gray-900 dark:text-white font-medium mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{exp.company_name}</div>
{exp.description && ( {exp.description && (
<p class="text-gray-600 dark:text-gray-300 leading-relaxed text-base"> <p class="text-gray-600 dark:text-gray-300 leading-relaxed text-base">
{exp.description} {exp.description}
@@ -86,12 +85,12 @@ export const EducationSection = ({ education }: any) => (
</h2> </h2>
<div class="grid gap-6"> <div class="grid gap-6">
{education.map((edu: any) => ( {education.map((edu: any) => (
<div class="bg-gray-50 dark:bg-gray-800/50 p-6 rounded-xl border border-gray-100 dark:border-gray-700 hover:border-purple-500/30 transition-colors"> <div class="bg-gray-50 dark:bg-gray-800/50 p-6 rounded-xl border border-gray-100 dark:border-gray-700 hover:border-purple-500/30 transition-colors group">
<div class="flex flex-col md:flex-row md:justify-between md:items-baseline mb-2"> <div class="flex flex-col md:flex-row md:justify-between md:items-baseline mb-2">
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{edu.institution}</h3> <h3 class="text-lg font-bold text-purple-600 dark:text-purple-400">{edu.degree}</h3>
<span class="text-sm text-gray-500 font-mono">{edu.start_date} {edu.end_date || "Present"}</span> <span class="text-sm text-gray-500 font-mono">{edu.start_date}{edu.start_date && ' — '}{edu.end_date || "Present"}</span>
</div> </div>
<div class="text-purple-600 dark:text-purple-400 font-medium">{edu.degree}</div> <div class="text-gray-900 dark:text-white font-medium group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors">{edu.institution}</div>
{edu.description && <p class="mt-2 text-gray-600 dark:text-gray-400 text-sm">{edu.description}</p>} {edu.description && <p class="mt-2 text-gray-600 dark:text-gray-400 text-sm">{edu.description}</p>}
</div> </div>
))} ))}
@@ -99,6 +98,37 @@ export const EducationSection = ({ education }: any) => (
</section> </section>
); );
export const ProjectsSection = ({ projects }: any) => (
<section id="projects" class="py-12">
<h2 class="text-3xl font-bold mb-12 text-gray-900 dark:text-white flex items-center gap-3">
<span class="w-8 h-1 bg-orange-500 rounded-full"></span>
Projects
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{projects.map((proj: any) => (
<a href={proj.project_url} target="_blank" class="group block bg-gray-50 dark:bg-gray-800/50 rounded-xl overflow-hidden border border-gray-100 dark:border-gray-700 hover:border-orange-500/50 transition-all hover:shadow-lg hover:-translate-y-1">
{proj.image_url && (
<div class="h-48 bg-gray-200 dark:bg-gray-700 overflow-hidden">
<img src={proj.image_url} alt={proj.name} class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
</div>
)}
<div class="p-6">
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-orange-600 dark:group-hover:text-orange-400 transition-colors mb-2">{proj.name}</h3>
{proj.description && <p class="text-gray-600 dark:text-gray-300 text-sm mb-4 leading-relaxed">{proj.description}</p>}
{proj.tech_stack && (
<div class="flex flex-wrap gap-2">
{proj.tech_stack.split(',').map((tech: string) => (
<span class="text-xs font-mono px-2 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md">{tech.trim()}</span>
))}
</div>
)}
</div>
</a>
))}
</div>
</section>
);
export const SkillsSection = ({ skills }: any) => ( export const SkillsSection = ({ skills }: any) => (
<section id="skills" class="py-16"> <section id="skills" class="py-16">
<h2 class="text-3xl font-bold mb-10 text-gray-900 dark:text-white flex items-center gap-3"> <h2 class="text-3xl font-bold mb-10 text-gray-900 dark:text-white flex items-center gap-3">

View File

@@ -0,0 +1,54 @@
import { Database } from "bun:sqlite";
export function up(db: Database) {
db.run("PRAGMA foreign_keys = ON;");
db.run(`CREATE TABLE IF NOT EXISTS languages (code TEXT PRIMARY KEY);`);
db.run(`
CREATE TABLE IF NOT EXISTS profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
email TEXT NOT NULL,
phone TEXT,
website TEXT,
github_url TEXT,
linkedin_url TEXT,
avatar_url TEXT
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS profile_translations (
profile_id INTEGER NOT NULL,
language_code TEXT NOT NULL,
name TEXT NOT NULL,
job_title TEXT NOT NULL,
summary TEXT,
location TEXT,
PRIMARY KEY (profile_id, language_code),
FOREIGN KEY (profile_id) REFERENCES profile(id) ON DELETE CASCADE,
FOREIGN KEY (language_code) REFERENCES languages(code)
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
icon TEXT,
display_order INTEGER DEFAULT 0
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS skill_translations (
skill_id INTEGER NOT NULL,
language_code TEXT NOT NULL,
name TEXT NOT NULL,
category_display TEXT,
PRIMARY KEY (skill_id, language_code),
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
FOREIGN KEY (language_code) REFERENCES languages(code)
);
`);
}

View File

@@ -0,0 +1,50 @@
import { Database } from "bun:sqlite";
export function up(db: Database) {
// Experience
db.run(`
CREATE TABLE IF NOT EXISTS experience (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_date TEXT NOT NULL,
end_date TEXT,
company_url TEXT
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS experience_translations (
experience_id INTEGER NOT NULL,
language_code TEXT NOT NULL,
company_name TEXT NOT NULL,
role TEXT NOT NULL,
description TEXT,
location TEXT,
PRIMARY KEY (experience_id, language_code),
FOREIGN KEY (experience_id) REFERENCES experience(id) ON DELETE CASCADE,
FOREIGN KEY (language_code) REFERENCES languages(code)
);
`);
// Education
db.run(`
CREATE TABLE IF NOT EXISTS education (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_date TEXT NOT NULL,
end_date TEXT,
institution_url TEXT
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS education_translations (
education_id INTEGER NOT NULL,
language_code TEXT NOT NULL,
institution TEXT NOT NULL,
degree TEXT NOT NULL,
description TEXT,
PRIMARY KEY (education_id, language_code),
FOREIGN KEY (education_id) REFERENCES education(id) ON DELETE CASCADE,
FOREIGN KEY (language_code) REFERENCES languages(code)
);
`);
}

View File

@@ -0,0 +1,15 @@
import { Database } from "bun:sqlite";
export function up(db: Database) {
try {
db.run("ALTER TABLE experience ADD COLUMN display_order INTEGER DEFAULT 0");
} catch (e) {
// Column likely already exists, ignore
}
try {
db.run("ALTER TABLE education ADD COLUMN display_order INTEGER DEFAULT 0");
} catch (e) {
// Column likely already exists, ignore
}
}

View File

@@ -0,0 +1,25 @@
import { Database } from "bun:sqlite";
export function up(db: Database) {
db.run(`
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_url TEXT,
image_url TEXT,
tech_stack TEXT,
display_order INTEGER DEFAULT 0
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS project_translations (
project_id INTEGER NOT NULL,
language_code TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
PRIMARY KEY (project_id, language_code),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (language_code) REFERENCES languages(code)
);
`);
}

48
src/db/migrator.ts Normal file
View File

@@ -0,0 +1,48 @@
import { Database } from "bun:sqlite";
import { db } from "./schema";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
async function migrate() {
console.log("🔄 Starting database migrations...");
// 1. Create migrations table if it doesn't exist
db.run(`
CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// 2. Get list of applied migrations
const applied = new Set(
db.query("SELECT name FROM _migrations").all().map((row: any) => row.name)
);
// 3. Get list of migration files
const migrationsDir = join(import.meta.dir, "migrations");
const files = (await readdir(migrationsDir)).filter(f => f.endsWith(".ts")).sort();
// 4. Run pending migrations
for (const file of files) {
if (!applied.has(file)) {
console.log(` Running migration: ${file}`);
const migration = await import(join(migrationsDir, file));
db.transaction(() => {
if (migration.up) migration.up(db);
db.run("INSERT INTO _migrations (name) VALUES (?)", [file]);
})();
console.log(` ✅ Applied: ${file}`);
}
}
console.log("✨ Migrations complete.");
}
if (import.meta.main) {
migrate();
}

View File

@@ -33,10 +33,10 @@ export function deleteExperience(id: number) {
} }
export function createExperience() { export function createExperience() {
const maxOrder = db.query("SELECT MAX(display_order) FROM experience").get() as number || 0;
const res = db.run(` const res = db.run(`
INSERT INTO experience (start_date, display_order) VALUES ('2024-01', 0) INSERT INTO experience (start_date, display_order) VALUES ('2024-01', $order)
`); `, { $order: maxOrder + 1 });
// Initialize translations
const id = (res as any).lastInsertRowid; 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 (?, '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]); db.run(`INSERT INTO experience_translations (experience_id, language_code, company_name, role) VALUES (?, 'de', 'Neue Firma', 'Neuer Job')`, [id]);
@@ -62,13 +62,23 @@ export function updateExperience(id: number, data: any) {
} }
} }
export function updateExperienceOrder(ids: number[]) {
db.transaction(() => {
const stmt = db.prepare("UPDATE experience SET display_order = $order WHERE id = $id");
ids.forEach((id, index) => {
stmt.run({ $order: index, $id: id });
});
})();
}
// --- Education --- // --- Education ---
export function deleteEducation(id: number) { export function deleteEducation(id: number) {
db.run("DELETE FROM education WHERE id = $id", { $id: id }); db.run("DELETE FROM education WHERE id = $id", { $id: id });
} }
export function createEducation() { export function createEducation() {
const res = db.run(`INSERT INTO education (start_date, display_order) VALUES ('2024-01', 0)`); const maxOrder = db.query("SELECT MAX(display_order) FROM education").get() as number || 0;
const res = db.run(`INSERT INTO education (start_date, display_order) VALUES ('2024-01', $order)`, { $order: maxOrder + 1 });
const id = (res as any).lastInsertRowid; 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 (?, 'en', 'New Institution', 'Degree')`, [id]);
db.run(`INSERT INTO education_translations (education_id, language_code, institution, degree) VALUES (?, 'de', 'Neue Institution', 'Abschluss')`, [id]); db.run(`INSERT INTO education_translations (education_id, language_code, institution, degree) VALUES (?, 'de', 'Neue Institution', 'Abschluss')`, [id]);
@@ -92,3 +102,53 @@ export function updateEducation(id: number, data: any) {
}); });
} }
} }
export function updateEducationOrder(ids: number[]) {
db.transaction(() => {
const stmt = db.prepare("UPDATE education SET display_order = $order WHERE id = $id");
ids.forEach((id, index) => {
stmt.run({ $order: index, $id: id });
});
})();
}
// --- Projects ---
export function deleteProject(id: number) {
db.run("DELETE FROM projects WHERE id = $id", { $id: id });
}
export function createProject() {
const maxOrder = db.query("SELECT MAX(display_order) FROM projects").get() as number || 0;
const res = db.run(`INSERT INTO projects (display_order) VALUES ($order)`, { $order: maxOrder + 1 });
const id = (res as any).lastInsertRowid;
db.run(`INSERT INTO project_translations (project_id, language_code, name, description) VALUES (?, 'en', 'New Project', 'Description')`, [id]);
db.run(`INSERT INTO project_translations (project_id, language_code, name, description) VALUES (?, 'de', 'Neues Projekt', 'Beschreibung')`, [id]);
return id;
}
export function updateProject(id: number, data: any) {
db.run(`
UPDATE projects SET project_url = $url, image_url = $img, tech_stack = $stack, display_order = $order
WHERE id = $id
`, { $url: data.project_url, $img: data.image_url, $stack: data.tech_stack, $order: data.display_order || 0, $id: id });
for (const lang of ["en", "de"]) {
db.run(`
UPDATE project_translations
SET name = $name, description = $desc
WHERE project_id = $id AND language_code = $lang
`, {
$name: data[`name_${lang}`], $desc: data[`description_${lang}`],
$id: id, $lang: lang
});
}
}
export function updateProjectOrder(ids: number[]) {
db.transaction(() => {
const stmt = db.prepare("UPDATE projects SET display_order = $order WHERE id = $id");
ids.forEach((id, index) => {
stmt.run({ $order: index, $id: id });
});
})();
}

View File

@@ -24,9 +24,11 @@ interface ExperienceTranslation {
} }
interface Experience extends ExperienceTranslation { interface Experience extends ExperienceTranslation {
id: number; // Add ID for ordering
start_date: string; start_date: string;
end_date: string | null; end_date: string | null;
company_url: string | null; company_url: string | null;
display_order: number; // Re-added
} }
interface EducationTranslation { interface EducationTranslation {
@@ -36,10 +38,11 @@ interface EducationTranslation {
} }
interface Education extends EducationTranslation { interface Education extends EducationTranslation {
id: number; // Add ID for ordering
start_date: string; start_date: string;
end_date: string | null; end_date: string | null;
institution_url: string | null; institution_url: string | null;
display_order: number; display_order: number; // Re-added
} }
interface SkillTranslation { interface SkillTranslation {
@@ -53,6 +56,19 @@ interface Skill extends SkillTranslation {
display_order: number; display_order: number;
} }
interface ProjectTranslation {
name: string;
description: string | null;
}
interface Project extends ProjectTranslation {
id: number;
project_url: string | null;
image_url: string | null;
tech_stack: string | null;
display_order: number;
}
export function getProfile(lang: string): Profile | null { export function getProfile(lang: string): Profile | null {
const profile = db.query(` const profile = db.query(`
SELECT p.email, p.phone, p.website, p.github_url, p.linkedin_url, p.avatar_url, SELECT p.email, p.phone, p.website, p.github_url, p.linkedin_url, p.avatar_url,
@@ -66,28 +82,40 @@ export function getProfile(lang: string): Profile | null {
export function getExperience(lang: string): Experience[] { export function getExperience(lang: string): Experience[] {
const experience = db.query(` const experience = db.query(`
SELECT e.start_date, e.end_date, e.company_url, e.display_order, SELECT e.id, e.start_date, e.end_date, e.company_url, e.display_order,
et.company_name, et.role, et.description, et.location et.company_name, et.role, et.description, et.location
FROM experience e FROM experience e
JOIN experience_translations et ON e.id = et.experience_id JOIN experience_translations et ON e.id = et.experience_id
WHERE et.language_code = $lang WHERE et.language_code = $lang
ORDER BY e.display_order ASC, e.start_date DESC ORDER BY e.display_order ASC
`).all({ $lang: lang }) as Experience[]; `).all({ $lang: lang }) as Experience[];
return experience; return experience;
} }
export function getEducation(lang: string): Education[] { export function getEducation(lang: string): Education[] {
const education = db.query(` const education = db.query(`
SELECT e.start_date, e.end_date, e.institution_url, e.display_order, SELECT e.id, e.start_date, e.end_date, e.institution_url, e.display_order,
et.institution, et.degree, et.description et.institution, et.degree, et.description
FROM education e FROM education e
JOIN education_translations et ON e.id = et.education_id JOIN education_translations et ON e.id = et.education_id
WHERE et.language_code = $lang WHERE et.language_code = $lang
ORDER BY e.display_order ASC, e.start_date DESC ORDER BY e.display_order ASC
`).all({ $lang: lang }) as Education[]; `).all({ $lang: lang }) as Education[];
return education; return education;
} }
export function getProjects(lang: string): Project[] {
const projects = db.query(`
SELECT p.id, p.project_url, p.image_url, p.tech_stack, p.display_order,
pt.name, pt.description
FROM projects p
JOIN project_translations pt ON p.id = pt.project_id
WHERE pt.language_code = $lang
ORDER BY p.display_order ASC
`).all({ $lang: lang }) as Project[];
return projects;
}
export function getSkills(lang: string): Skill[] { export function getSkills(lang: string): Skill[] {
const skills = db.query(` const skills = db.query(`
SELECT s.category, s.icon, s.display_order, SELECT s.category, s.icon, s.display_order,
@@ -105,6 +133,7 @@ export function getAllData(lang: string) {
profile: getProfile(lang), profile: getProfile(lang),
experience: getExperience(lang), experience: getExperience(lang),
education: getEducation(lang), education: getEducation(lang),
projects: getProjects(lang),
skills: getSkills(lang), skills: getSkills(lang),
}; };
} }
@@ -125,9 +154,9 @@ export function getAdminProfile() {
} }
export function getAdminExperience() { export function getAdminExperience() {
const exps = db.query(`SELECT * FROM experience ORDER BY display_order ASC, start_date DESC`).all() as any[]; const exps = db.query(`SELECT e.*, MAX(e.display_order) OVER () AS max_order FROM experience e ORDER BY e.display_order ASC`).all() as any[];
return exps.map(e => { return exps.map(e => {
const trans = db.query(`SELECT * FROM experience_translations WHERE experience_id = $id`, { $id: e.id }).all() as any[]; const trans = db.query(`SELECT * FROM experience_translations WHERE experience_id = $id`).all({ $id: e.id }) as any[];
trans.forEach(t => { trans.forEach(t => {
e[`company_name_${t.language_code}`] = t.company_name; e[`company_name_${t.language_code}`] = t.company_name;
e[`role_${t.language_code}`] = t.role; e[`role_${t.language_code}`] = t.role;
@@ -138,10 +167,24 @@ export function getAdminExperience() {
}); });
} }
export function getAdminExperienceById(id: number) {
const e = db.query(`SELECT * FROM experience WHERE id = $id`).get({ $id: id }) as any;
if (!e) return null;
const trans = db.query(`SELECT * FROM experience_translations WHERE experience_id = $id`).all({ $id: id }) 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() { export function getAdminEducation() {
const edus = db.query(`SELECT * FROM education ORDER BY display_order ASC, start_date DESC`).all() as any[]; const edus = db.query(`SELECT e.*, MAX(e.display_order) OVER () AS max_order FROM education e ORDER BY e.display_order ASC`).all() as any[];
return edus.map(e => { return edus.map(e => {
const trans = db.query(`SELECT * FROM education_translations WHERE education_id = $id`, { $id: e.id }).all() as any[]; const trans = db.query(`SELECT * FROM education_translations WHERE education_id = $id`).all({ $id: e.id }) as any[];
trans.forEach(t => { trans.forEach(t => {
e[`institution_${t.language_code}`] = t.institution; e[`institution_${t.language_code}`] = t.institution;
e[`degree_${t.language_code}`] = t.degree; e[`degree_${t.language_code}`] = t.degree;
@@ -151,4 +194,39 @@ export function getAdminEducation() {
}); });
} }
export function getAdminEducationById(id: number) {
const e = db.query(`SELECT * FROM education WHERE id = $id`).get({ $id: id }) as any;
if (!e) return null;
const trans = db.query(`SELECT * FROM education_translations WHERE education_id = $id`).all({ $id: id }) 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;
}
export function getAdminProjects() {
const projs = db.query(`SELECT p.*, MAX(p.display_order) OVER () AS max_order FROM projects p ORDER BY p.display_order ASC`).all() as any[];
return projs.map(p => {
const trans = db.query(`SELECT * FROM project_translations WHERE project_id = $id`).all({ $id: p.id }) as any[];
trans.forEach(t => {
p[`name_${t.language_code}`] = t.name;
p[`description_${t.language_code}`] = t.description;
});
return p;
});
}
export function getAdminProjectById(id: number) {
const p = db.query(`SELECT * FROM projects WHERE id = $id`).get({ $id: id }) as any;
if (!p) return null;
const trans = db.query(`SELECT * FROM project_translations WHERE project_id = $id`).all({ $id: id }) as any[];
trans.forEach(t => {
p[`name_${t.language_code}`] = t.name;
p[`description_${t.language_code}`] = t.description;
});
return p;
}

View File

@@ -5,117 +5,4 @@ const db = new Database("cv.sqlite", { create: true });
// Enable foreign keys // Enable foreign keys
db.run("PRAGMA foreign_keys = ON;"); db.run("PRAGMA foreign_keys = ON;");
export function initDB() { export { db };
// 1. Languages Table (to ensure referential integrity)
db.run(`
CREATE TABLE IF NOT EXISTS languages (
code TEXT PRIMARY KEY
);
`);
// 2. Profile (Singleton - structural info)
db.run(`
CREATE TABLE IF NOT EXISTS profile (
id INTEGER PRIMARY KEY CHECK (id = 1), -- Ensure only one profile exists
email TEXT NOT NULL,
phone TEXT,
website TEXT,
github_url TEXT,
linkedin_url TEXT,
avatar_url TEXT
);
`);
// 3. Profile Translations
db.run(`
CREATE TABLE IF NOT EXISTS profile_translations (
profile_id INTEGER NOT NULL,
language_code TEXT NOT NULL,
name TEXT NOT NULL,
job_title TEXT NOT NULL,
summary TEXT,
location TEXT,
PRIMARY KEY (profile_id, language_code),
FOREIGN KEY (profile_id) REFERENCES profile(id) ON DELETE CASCADE,
FOREIGN KEY (language_code) REFERENCES languages(code)
);
`);
// 4. Experience (Structural)
db.run(`
CREATE TABLE IF NOT EXISTS experience (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_date TEXT NOT NULL, -- ISO8601 YYYY-MM
end_date TEXT, -- NULL = Current
company_url TEXT,
display_order INTEGER DEFAULT 0
);
`);
// 5. Experience Translations
db.run(`
CREATE TABLE IF NOT EXISTS experience_translations (
experience_id INTEGER NOT NULL,
language_code TEXT NOT NULL,
company_name TEXT NOT NULL,
role TEXT NOT NULL,
description TEXT, -- Supports Markdown/HTML
location TEXT,
PRIMARY KEY (experience_id, language_code),
FOREIGN KEY (experience_id) REFERENCES experience(id) ON DELETE CASCADE,
FOREIGN KEY (language_code) REFERENCES languages(code)
);
`);
// 6. Education (Structural)
db.run(`
CREATE TABLE IF NOT EXISTS education (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_date TEXT NOT NULL,
end_date TEXT,
institution_url TEXT,
display_order INTEGER DEFAULT 0
);
`);
// 7. Education Translations
db.run(`
CREATE TABLE IF NOT EXISTS education_translations (
education_id INTEGER NOT NULL,
language_code TEXT NOT NULL,
institution TEXT NOT NULL,
degree TEXT NOT NULL,
description TEXT,
PRIMARY KEY (education_id, language_code),
FOREIGN KEY (education_id) REFERENCES education(id) ON DELETE CASCADE,
FOREIGN KEY (language_code) REFERENCES languages(code)
);
`);
// 8. Skills (Categories & Items)
// Simply storing skills as items with a category.
db.run(`
CREATE TABLE IF NOT EXISTS skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL, -- e.g., "frontend", "backend", "tools" (Internal key)
icon TEXT, -- class name for an icon library
display_order INTEGER DEFAULT 0
);
`);
db.run(`
CREATE TABLE IF NOT EXISTS skill_translations (
skill_id INTEGER NOT NULL,
language_code TEXT NOT NULL,
name TEXT NOT NULL, -- The display name
category_display TEXT, -- "Frontend Development" vs "Frontend Entwicklung"
PRIMARY KEY (skill_id, language_code),
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
FOREIGN KEY (language_code) REFERENCES languages(code)
);
`);
console.log("Database schema initialized.");
}
export { db };

View File

@@ -1,8 +1,15 @@
import { db, initDB } from "./schema"; import { db } from "./schema";
import { spawnSync } from "bun";
export function seedDB() { export function seedDB() {
// Initialize tables first // Run migrations first
initDB(); console.log("Running migrations...");
const migrate = spawnSync(["bun", "run", "src/db/migrator.ts"]);
console.log(migrate.stdout.toString());
if (migrate.exitCode !== 0) {
console.error("Migration failed:", migrate.stderr.toString());
return;
}
// Check if data exists to avoid duplicates // Check if data exists to avoid duplicates
const check = db.query("SELECT count(*) as count FROM languages").get() as { count: number }; const check = db.query("SELECT count(*) as count FROM languages").get() as { count: number };
@@ -100,6 +107,19 @@ export function seedDB() {
insertSkillTrans.run({ $sid: s1.id, $code: "en", $name: "TypeScript", $catDisplay: "Technologies" }); insertSkillTrans.run({ $sid: s1.id, $code: "en", $name: "TypeScript", $catDisplay: "Technologies" });
insertSkillTrans.run({ $sid: s1.id, $code: "de", $name: "TypeScript", $catDisplay: "Technologien" }); insertSkillTrans.run({ $sid: s1.id, $code: "de", $name: "TypeScript", $catDisplay: "Technologien" });
// 6. Projects
const insertProj = db.prepare(`
INSERT INTO projects (project_url, image_url, tech_stack, display_order) VALUES ($url, $img, $stack, $order) RETURNING id
`);
const insertProjTrans = db.prepare(`
INSERT INTO project_translations (project_id, language_code, name, description) VALUES ($pid, $code, $name, $desc)
`);
const p1 = insertProj.get({ $url: "https://github.com/example/cv-app", $img: "https://placehold.co/600x400", $stack: "ElysiaJS, SQLite, Tailwind", $order: 1 }) as { id: number };
insertProjTrans.run({ $pid: p1.id, $code: "en", $name: "CV Website", $desc: "A high-performance SSR portfolio built with Bun." });
insertProjTrans.run({ $pid: p1.id, $code: "de", $name: "CV Webseite", $desc: "Ein hochperformantes SSR-Portfolio basierend auf Bun." });
console.log("Seeding complete."); console.log("Seeding complete.");
} }

View File

@@ -4,23 +4,16 @@ import { cookie } from "@elysiajs/cookie";
import { KeyCloak, generateState, generateCodeVerifier } from "arctic"; import { KeyCloak, generateState, generateCodeVerifier } from "arctic";
import * as elements from "typed-html"; import * as elements from "typed-html";
import { BaseHtml } from "../components/BaseHtml"; import { BaseHtml } from "../components/BaseHtml";
import { getAdminProfile, getAdminExperience, getAdminEducation } from "../db/queries"; import { InputGroup, TextAreaGroup, ExperienceForm, EducationForm, ProjectForm } from "../components/AdminForms";
import { updateProfile, createExperience, updateExperience, deleteExperience, createEducation, updateEducation, deleteEducation } from "../db/mutations"; 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) // Initialize Keycloak (Arctic)
// Ensure these env vars are set!
const realmURL = process.env.KEYCLOAK_REALM_URL || ""; const realmURL = process.env.KEYCLOAK_REALM_URL || "";
const clientId = process.env.KEYCLOAK_CLIENT_ID || ""; const clientId = process.env.KEYCLOAK_CLIENT_ID || "";
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET || ""; const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET || "";
const redirectURI = process.env.KEYCLOAK_REDIRECT_URI || "http://localhost:3000/admin/callback"; 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 keycloak = new KeyCloak(realmURL, clientId, clientSecret, redirectURI);
const AdminLayout = ({ children }: elements.Children) => ( const AdminLayout = ({ children }: elements.Children) => (
@@ -38,24 +31,58 @@ const AdminLayout = ({ children }: elements.Children) => (
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
{children} {children}
</div> </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> </div>
); );
const InputGroup = ({ label, name, value, type = "text", required = false }: any) => (
<div class="mb-4">
<label class="block text-sm font-bold mb-1 text-gray-700 dark:text-gray-300">{label}</label>
<input type={type} name={name} value={value || ""} required={required}
class="w-full p-2 border rounded dark:bg-gray-800 dark:border-gray-600" />
</div>
);
const TextAreaGroup = ({ label, name, value }: any) => (
<div class="mb-4">
<label class="block text-sm font-bold mb-1 text-gray-700 dark:text-gray-300">{label}</label>
<textarea name={name} rows="4" class="w-full p-2 border rounded dark:bg-gray-800 dark:border-gray-600">{value || ""}</textarea>
</div>
);
export const adminRoutes = new Elysia() export const adminRoutes = new Elysia()
.use(cookie()) .use(cookie())
.use(html()) .use(html())
@@ -66,29 +93,19 @@ export const adminRoutes = new Elysia()
}; };
}) })
// 1. Login Page (Redirect to Keycloak) // 1. Login Page
.get("/admin/login", ({ isLoggedIn, set, html, cookie: { oauth_state, oauth_code_verifier } }) => { .get("/admin/login", ({ isLoggedIn, set, html, cookie: { oauth_state, oauth_code_verifier } }) => {
if (isLoggedIn) return Response.redirect("/admin/dashboard"); if (isLoggedIn) return Response.redirect("/admin/dashboard");
// Generate State & Verifier for PKCE flow
const state = generateState(); const state = generateState();
const codeVerifier = generateCodeVerifier(); 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({ oauth_state.set({
value: state, value: state,
path: "/", path: "/",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
httpOnly: true, httpOnly: true,
maxAge: 600 // 10 min maxAge: 600
}); });
oauth_code_verifier.set({ oauth_code_verifier.set({
@@ -96,12 +113,11 @@ export const adminRoutes = new Elysia()
path: "/", path: "/",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
httpOnly: true, httpOnly: true,
maxAge: 600 // 10 min maxAge: 600
}); });
try { try {
const url = keycloak.createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]); const url = keycloak.createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
return html( return html(
<BaseHtml> <BaseHtml>
<div class="flex h-screen items-center justify-center bg-gray-100 dark:bg-gray-900"> <div class="flex h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
@@ -131,23 +147,15 @@ export const adminRoutes = new Elysia()
} }
try { try {
// Exchange code for tokens
const tokens = await keycloak.validateAuthorizationCode(code, storedVerifier); 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({ auth_session.set({
value: tokens.accessToken(), // Or just "true" if you don't need the token value: tokens.accessToken(),
httpOnly: true, httpOnly: true,
path: "/", path: "/",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
maxAge: 86400 // 1 day maxAge: 86400 // 1 day
}); });
// Cleanup
oauth_state.remove(); oauth_state.remove();
oauth_code_verifier.remove(); oauth_code_verifier.remove();
@@ -160,7 +168,6 @@ export const adminRoutes = new Elysia()
.get("/admin/logout", ({ cookie: { auth_session }, set }) => { .get("/admin/logout", ({ cookie: { auth_session }, set }) => {
auth_session.remove(); auth_session.remove();
// Optional: Redirect to Keycloak logout endpoint as well
return Response.redirect("/admin/login"); return Response.redirect("/admin/login");
}) })
@@ -169,11 +176,12 @@ export const adminRoutes = new Elysia()
if (!isLoggedIn) return Response.redirect("/admin/login"); if (!isLoggedIn) return Response.redirect("/admin/login");
}) })
// ... Dashboard and POST handlers remain the same ... // Dashboard
.get("/admin/dashboard", ({ html }) => { .get("/admin/dashboard", ({ html }) => {
const profile = getAdminProfile(); const profile = getAdminProfile();
const experience = getAdminExperience(); const experience = getAdminExperience();
const education = getAdminEducation(); const education = getAdminEducation();
const projects = getAdminProjects();
return html( return html(
<BaseHtml> <BaseHtml>
@@ -213,41 +221,28 @@ export const adminRoutes = new Elysia()
</form> </form>
</div> </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 */} {/* Experience Section */}
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8"> <div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold">Experience</h2> <h2 class="text-2xl font-bold">Experience</h2>
<form action="/admin/experience/new" method="POST"> <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>
<button class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">+ Add Job</button>
</form>
</div> </div>
<div class="space-y-6"> <div id="experience-list" class="">
{experience.map((exp: any) => ( {experience.map((exp: any) => (
<form action={`/admin/experience/${exp.id}`} method="POST" class="border dark:border-gray-700 p-4 rounded"> <ExperienceForm exp={exp} />
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<InputGroup label="Start Date" name="start_date" value={exp.start_date} />
<InputGroup label="End Date" name="end_date" value={exp.end_date} />
<InputGroup label="Order" name="display_order" value={exp.display_order} type="number" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
<InputGroup label="Company" name="company_name_en" value={exp.company_name_en} />
<InputGroup label="Role" name="role_en" value={exp.role_en} />
<TextAreaGroup label="Description" name="description_en" value={exp.description_en} />
</div>
<div>
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
<InputGroup label="Company" name="company_name_de" value={exp.company_name_de} />
<InputGroup label="Role" name="role_de" value={exp.role_de} />
<TextAreaGroup label="Description" name="description_de" value={exp.description_de} />
</div>
</div>
<div class="flex justify-between mt-2">
<button formaction={`/admin/experience/${exp.id}/delete`} class="text-red-500 text-sm hover:underline">Delete</button>
<button class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600">Save</button>
</div>
</form>
))} ))}
</div> </div>
</div> </div>
@@ -256,37 +251,11 @@ export const adminRoutes = new Elysia()
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow"> <div class="bg-white dark:bg-gray-800 p-6 rounded shadow">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold">Education</h2> <h2 class="text-2xl font-bold">Education</h2>
<form action="/admin/education/new" method="POST"> <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>
<button class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">+ Add Education</button>
</form>
</div> </div>
<div class="space-y-6"> <div id="education-list" class="">
{education.map((edu: any) => ( {education.map((edu: any) => (
<form action={`/admin/education/${edu.id}`} method="POST" class="border dark:border-gray-700 p-4 rounded"> <EducationForm edu={edu} />
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<InputGroup label="Start Date" name="start_date" value={edu.start_date} />
<InputGroup label="End Date" name="end_date" value={edu.end_date} />
<InputGroup label="Order" name="display_order" value={edu.display_order} type="number" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="text-xs font-bold text-gray-500 mb-2">ENGLISH</div>
<InputGroup label="Institution" name="institution_en" value={edu.institution_en} />
<InputGroup label="Degree" name="degree_en" value={edu.degree_en} />
<TextAreaGroup label="Description" name="description_en" value={edu.description_en} />
</div>
<div>
<div class="text-xs font-bold text-gray-500 mb-2">GERMAN</div>
<InputGroup label="Institution" name="institution_de" value={edu.institution_de} />
<InputGroup label="Degree" name="degree_de" value={edu.degree_de} />
<TextAreaGroup label="Description" name="description_de" value={edu.description_de} />
</div>
</div>
<div class="flex justify-between mt-2">
<button formaction={`/admin/education/${edu.id}/delete`} class="text-red-500 text-sm hover:underline">Delete</button>
<button class="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600">Save</button>
</div>
</form>
))} ))}
</div> </div>
</div> </div>
@@ -295,32 +264,90 @@ export const adminRoutes = new Elysia()
</BaseHtml> </BaseHtml>
); );
}) })
// Handlers
// POST Handlers
.post("/admin/profile", ({ body, set }) => { .post("/admin/profile", ({ body, set }) => {
updateProfile(1, body); updateProfile(1, body);
return Response.redirect("/admin/dashboard"); 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 }) => { .post("/admin/experience/new", ({ set }) => {
createExperience(); const id = createExperience();
return Response.redirect("/admin/dashboard"); 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 }) => { .post("/admin/experience/:id", ({ params, body, set }) => {
updateExperience(Number(params.id), body); const id = Number(params.id);
return Response.redirect("/admin/dashboard"); updateExperience(id, body);
const updatedExp = getAdminExperienceById(id);
if (!updatedExp) return "";
return <ExperienceForm exp={updatedExp} />;
}) })
.post("/admin/experience/:id/delete", ({ params, set }) => { .post("/admin/experience/:id/delete", ({ params, set }) => {
deleteExperience(Number(params.id)); deleteExperience(Number(params.id));
return Response.redirect("/admin/dashboard"); return "";
}) })
.post("/admin/education/new", ({ set }) => {
createEducation(); // Education
return Response.redirect("/admin/dashboard"); .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 }) => { .post("/admin/education/:id", ({ params, body, set }) => {
updateEducation(Number(params.id), body); const id = Number(params.id);
return Response.redirect("/admin/dashboard"); updateEducation(id, body);
const updatedEdu = getAdminEducationById(id);
if (!updatedEdu) return "";
return <EducationForm edu={updatedEdu} />;
}) })
.post("/admin/education/:id/delete", ({ params, set }) => { .post("/admin/education/:id/delete", ({ params, set }) => {
deleteEducation(Number(params.id)); deleteEducation(Number(params.id));
return Response.redirect("/admin/dashboard"); return "";
}); });

View File

@@ -3,7 +3,7 @@ import { html } from "@elysiajs/html";
import * as elements from "typed-html"; import * as elements from "typed-html";
import { BaseHtml } from "../components/BaseHtml"; import { BaseHtml } from "../components/BaseHtml";
import { Layout } from "../components/Layout"; import { Layout } from "../components/Layout";
import { HeroSection, AboutSection, ExperienceSection, EducationSection, SkillsSection } from "../components/Sections"; import { HeroSection, AboutSection, ExperienceSection, EducationSection, SkillsSection, ProjectsSection } from "../components/Sections";
import { getAllData } from "../db/queries"; import { getAllData } from "../db/queries";
export const publicRoutes = new Elysia() export const publicRoutes = new Elysia()
@@ -41,6 +41,11 @@ export const publicRoutes = new Elysia()
{(data.education && data.education.length > 0) ? ( {(data.education && data.education.length > 0) ? (
<EducationSection education={data.education} /> <EducationSection education={data.education} />
) : ""} ) : ""}
{/* Projects Section */}
{(data.projects && data.projects.length > 0) ? (
<ProjectsSection projects={data.projects} />
) : ""}
<SkillsSection skills={data.skills} /> <SkillsSection skills={data.skills} />
</div> </div>