Compare commits
4 Commits
4dc258606e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6db5d323c4 | ||
|
|
3de8f6a971 | ||
|
|
be0be3bd00 | ||
|
|
3c990e5ab6 |
48
Dockerfile
Normal file
48
Dockerfile
Normal 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
85
GEMINI.md
Normal 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.
|
||||
193
bun.lock
193
bun.lock
@@ -8,10 +8,13 @@
|
||||
"@elysiajs/cookie": "^0.8.0",
|
||||
"@elysiajs/html": "^1.4.0",
|
||||
"@kitajs/ts-html-plugin": "^4.1.3",
|
||||
"alpinejs": "^3.15.2",
|
||||
"arctic": "^3.7.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"elysia": "^1.4.16",
|
||||
"postcss": "^8.5.6",
|
||||
"puppeteer": "^24.31.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typed-html": "^3.0.1",
|
||||
},
|
||||
@@ -21,6 +24,10 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -41,12 +48,16 @@
|
||||
|
||||
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
|
||||
|
||||
"@puppeteer/browsers": ["@puppeteer/browsers@2.10.13", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -55,46 +66,116 @@
|
||||
|
||||
"@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"b4a": ["b4a@1.7.3", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q=="],
|
||||
|
||||
"bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="],
|
||||
|
||||
"bare-fs": ["bare-fs@4.5.1", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-zGUCsm3yv/ePt2PHNbVxjjn0nNB1MkIaR4wOCxJ2ig5pCf5cCVAYJXVhQg/3OhhJV6DB1ts7Hv0oUaElc2TPQg=="],
|
||||
|
||||
"bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="],
|
||||
|
||||
"bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="],
|
||||
|
||||
"bare-stream": ["bare-stream@2.7.0", "", { "dependencies": { "streamx": "^2.21.0" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-buffer", "bare-events"] }, "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A=="],
|
||||
|
||||
"bare-url": ["bare-url@2.3.2", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.30", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA=="],
|
||||
|
||||
"basic-ftp": ["basic-ftp@5.0.5", "", {}, "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", "electron-to-chromium": "^1.5.249", "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001756", "", {}, "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chromium-bidi": ["chromium-bidi@11.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"cookie": ["cookie@0.5.0", "", {}, "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
|
||||
|
||||
"devtools-protocol": ["devtools-protocol@0.0.1521046", "", {}, "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.259", "", {}, "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ=="],
|
||||
|
||||
"elysia": ["elysia@1.4.16", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.3", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-KZtKN160/bdWVKg2hEgyoNXY8jRRquc+m6PboyisaLZL891I+Ufb7Ja6lDAD7vMQur8sLEWIcidZOzj5lWw9UA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
|
||||
|
||||
"exact-mirror": ["exact-mirror@0.2.3", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-aLdARfO0W0ntufjDyytUJQMbNXoB9g+BbA8KcgIq4XOOTYRw48yUGON/Pr64iDrYNZKcKvKbqE0MPW56FF2BXA=="],
|
||||
|
||||
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
|
||||
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
|
||||
|
||||
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
|
||||
|
||||
"file-type": ["file-type@21.1.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg=="],
|
||||
|
||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
@@ -103,28 +184,100 @@
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
||||
|
||||
"get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
|
||||
|
||||
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
||||
|
||||
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
|
||||
|
||||
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
||||
|
||||
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||
|
||||
"puppeteer": ["puppeteer@24.31.0", "", { "dependencies": { "@puppeteer/browsers": "2.10.13", "chromium-bidi": "11.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1521046", "puppeteer-core": "24.31.0", "typed-query-selector": "^2.12.0" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-q8y5yLxLD8xdZdzNWqdOL43NbfvUOp60SYhaLZQwHC9CdKldxQKXOyJAciOr7oUJfyAH/KgB2wKvqT2sFKoVXA=="],
|
||||
|
||||
"puppeteer-core": ["puppeteer-core@24.31.0", "", { "dependencies": { "@puppeteer/browsers": "2.10.13", "chromium-bidi": "11.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1521046", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.3.9", "ws": "^8.18.3" } }, "sha512-pnAohhSZipWQoFpXuGV7xCZfaGhqcBR9C4pVrU0QSrcMi7tQMH9J9lDBqBvyMAHQqe8HCARuREqFuVKRQOgTvg=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||
|
||||
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
|
||||
|
||||
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||
|
||||
"sortablejs": ["sortablejs@1.15.6", "", {}, "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
@@ -133,12 +286,20 @@
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
|
||||
|
||||
"tar-fs": ["tar-fs@3.1.1", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg=="],
|
||||
|
||||
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
|
||||
|
||||
"text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="],
|
||||
|
||||
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typed-html": ["typed-html@3.0.1", "", {}, "sha512-JKCM9zTfPDuPqQqdGZBWSEiItShliKkBFg5c6yOR8zth43v763XkAzTWaOlVqc0Y6p9ee8AaAbipGfUnCsYZUA=="],
|
||||
|
||||
"typed-query-selector": ["typed-query-selector@2.12.0", "", {}, "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
@@ -147,16 +308,48 @@
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
|
||||
|
||||
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.3.9", "", {}, "sha512-uIYvlRQ0PwtZR1EzHlTMol1G0lAlmOe6wPykF9a77AK3bkpvZHzIVxRE2ThOx5vjy2zISe0zhwf5rzuUfbo1PQ=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
|
||||
"@puppeteer/browsers/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"elysia/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"@puppeteer/browsers/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ services:
|
||||
|
||||
keycloak:
|
||||
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:
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
|
||||
@@ -22,6 +24,7 @@ services:
|
||||
KC_DB_PASSWORD: password
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_HOSTNAME_URL: http://localhost:8080/
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
@@ -34,4 +37,4 @@ volumes:
|
||||
|
||||
networks:
|
||||
keycloak_network:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
@@ -3,16 +3,20 @@
|
||||
"version": "1.0.50",
|
||||
"scripts": {
|
||||
"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": {
|
||||
"@elysiajs/cookie": "^0.8.0",
|
||||
"@elysiajs/html": "^1.4.0",
|
||||
"@kitajs/ts-html-plugin": "^4.1.3",
|
||||
"alpinejs": "^3.15.2",
|
||||
"arctic": "^3.7.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"elysia": "^1.4.16",
|
||||
"postcss": "^8.5.6",
|
||||
"puppeteer": "^24.31.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typed-html": "^3.0.1"
|
||||
},
|
||||
|
||||
35
realm-export.json
Normal file
35
realm-export.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
174
src/components/AdminForms.tsx
Normal file
174
src/components/AdminForms.tsx
Normal 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>
|
||||
);
|
||||
@@ -8,34 +8,184 @@ export const BaseHtml = ({ children }: elements.Children) => `
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CV Website</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
}
|
||||
// Dark mode toggle script
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.2/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
}
|
||||
// Dark mode toggle script
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
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>
|
||||
<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}
|
||||
</body>
|
||||
</html>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
@@ -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">
|
||||
{experience.map((exp: any) => (
|
||||
<div class="relative group">
|
||||
|
||||
|
||||
<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">
|
||||
{exp.start_date} — {exp.end_date || "Present"}
|
||||
{exp.start_date}{exp.start_date && ' — '}{exp.end_date || "Present"}
|
||||
</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">
|
||||
{exp.company_name}
|
||||
<h3 class="text-xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{exp.role}
|
||||
</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 && (
|
||||
<p class="text-gray-600 dark:text-gray-300 leading-relaxed text-base">
|
||||
{exp.description}
|
||||
@@ -86,12 +85,12 @@ export const EducationSection = ({ education }: any) => (
|
||||
</h2>
|
||||
<div class="grid gap-6">
|
||||
{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">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{edu.institution}</h3>
|
||||
<span class="text-sm text-gray-500 font-mono">{edu.start_date} — {edu.end_date || "Present"}</span>
|
||||
<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.start_date && ' — '}{edu.end_date || "Present"}</span>
|
||||
</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>}
|
||||
</div>
|
||||
))}
|
||||
@@ -99,6 +98,37 @@ export const EducationSection = ({ education }: any) => (
|
||||
</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) => (
|
||||
<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">
|
||||
|
||||
54
src/db/migrations/001_baseline.ts
Normal file
54
src/db/migrations/001_baseline.ts
Normal 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)
|
||||
);
|
||||
`);
|
||||
}
|
||||
50
src/db/migrations/002_experience_education.ts
Normal file
50
src/db/migrations/002_experience_education.ts
Normal 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)
|
||||
);
|
||||
`);
|
||||
}
|
||||
15
src/db/migrations/003_add_display_order.ts
Normal file
15
src/db/migrations/003_add_display_order.ts
Normal 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
|
||||
}
|
||||
}
|
||||
25
src/db/migrations/004_add_projects.ts
Normal file
25
src/db/migrations/004_add_projects.ts
Normal 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
48
src/db/migrator.ts
Normal 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();
|
||||
}
|
||||
@@ -33,10 +33,10 @@ export function deleteExperience(id: number) {
|
||||
}
|
||||
|
||||
export function createExperience() {
|
||||
const maxOrder = db.query("SELECT MAX(display_order) FROM experience").get() as number || 0;
|
||||
const res = db.run(`
|
||||
INSERT INTO experience (start_date, display_order) VALUES ('2024-01', 0)
|
||||
`);
|
||||
// Initialize translations
|
||||
INSERT INTO experience (start_date, display_order) VALUES ('2024-01', $order)
|
||||
`, { $order: maxOrder + 1 });
|
||||
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]);
|
||||
@@ -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 ---
|
||||
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 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;
|
||||
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]);
|
||||
@@ -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 });
|
||||
});
|
||||
})();
|
||||
}
|
||||
@@ -24,9 +24,11 @@ interface ExperienceTranslation {
|
||||
}
|
||||
|
||||
interface Experience extends ExperienceTranslation {
|
||||
id: number; // Add ID for ordering
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
company_url: string | null;
|
||||
display_order: number; // Re-added
|
||||
}
|
||||
|
||||
interface EducationTranslation {
|
||||
@@ -36,10 +38,11 @@ interface EducationTranslation {
|
||||
}
|
||||
|
||||
interface Education extends EducationTranslation {
|
||||
id: number; // Add ID for ordering
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
institution_url: string | null;
|
||||
display_order: number;
|
||||
display_order: number; // Re-added
|
||||
}
|
||||
|
||||
interface SkillTranslation {
|
||||
@@ -53,6 +56,19 @@ interface Skill extends SkillTranslation {
|
||||
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 {
|
||||
const profile = db.query(`
|
||||
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[] {
|
||||
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
|
||||
FROM experience e
|
||||
JOIN experience_translations et ON e.id = et.experience_id
|
||||
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[];
|
||||
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,
|
||||
SELECT e.id, 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
|
||||
ORDER BY e.display_order ASC
|
||||
`).all({ $lang: lang }) as 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[] {
|
||||
const skills = db.query(`
|
||||
SELECT s.category, s.icon, s.display_order,
|
||||
@@ -105,6 +133,7 @@ export function getAllData(lang: string) {
|
||||
profile: getProfile(lang),
|
||||
experience: getExperience(lang),
|
||||
education: getEducation(lang),
|
||||
projects: getProjects(lang),
|
||||
skills: getSkills(lang),
|
||||
};
|
||||
}
|
||||
@@ -125,9 +154,9 @@ export function getAdminProfile() {
|
||||
}
|
||||
|
||||
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 => {
|
||||
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 => {
|
||||
e[`company_name_${t.language_code}`] = t.company_name;
|
||||
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() {
|
||||
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 => {
|
||||
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 => {
|
||||
e[`institution_${t.language_code}`] = t.institution;
|
||||
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;
|
||||
}
|
||||
|
||||
115
src/db/schema.ts
115
src/db/schema.ts
@@ -5,117 +5,4 @@ const db = new Database("cv.sqlite", { create: true });
|
||||
// Enable foreign keys
|
||||
db.run("PRAGMA foreign_keys = ON;");
|
||||
|
||||
export function initDB() {
|
||||
// 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 };
|
||||
export { db };
|
||||
@@ -1,8 +1,15 @@
|
||||
import { db, initDB } from "./schema";
|
||||
import { db } from "./schema";
|
||||
import { spawnSync } from "bun";
|
||||
|
||||
export function seedDB() {
|
||||
// Initialize tables first
|
||||
initDB();
|
||||
// Run migrations first
|
||||
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
|
||||
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: "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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Elysia } from "elysia";
|
||||
import { html } from "@elysiajs/html";
|
||||
import { adminRoutes } from "./routes/admin";
|
||||
import { publicRoutes } from "./routes/public";
|
||||
import { pdfRoutes } from "./routes/pdf";
|
||||
|
||||
const app = new Elysia()
|
||||
.use(html())
|
||||
.use(adminRoutes)
|
||||
.use(publicRoutes)
|
||||
.use(pdfRoutes)
|
||||
.listen(3000);
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -4,23 +4,16 @@ 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";
|
||||
import { InputGroup, TextAreaGroup, ExperienceForm, EducationForm, ProjectForm } from "../components/AdminForms";
|
||||
import { getAdminProfile, getAdminExperience, getAdminEducation, getAdminProjects, getAdminExperienceById, getAdminEducationById, getAdminProjectById } from "../db/queries";
|
||||
import { updateProfile, createExperience, updateExperience, deleteExperience, createEducation, updateEducation, deleteEducation, updateExperienceOrder, updateEducationOrder, createProject, updateProject, deleteProject, updateProjectOrder } from "../db/mutations";
|
||||
|
||||
// Initialize Keycloak (Arctic)
|
||||
// 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) => (
|
||||
@@ -38,24 +31,58 @@ const AdminLayout = ({ children }: elements.Children) => (
|
||||
<div class="container mx-auto p-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* SortableJS Initialization Script */}
|
||||
<script>
|
||||
{`
|
||||
function initSortable(id, endpoint) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) {
|
||||
console.warn('Sortable container not found:', id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (el._sortable) return;
|
||||
|
||||
console.log('Initializing Sortable on:', id);
|
||||
el._sortable = new Sortable(el, {
|
||||
group: id,
|
||||
draggable: 'form',
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'bg-blue-100',
|
||||
onStart: function(evt) {
|
||||
console.log('Drag started', evt);
|
||||
},
|
||||
onEnd: function (evt) {
|
||||
console.log('Drag ended', evt);
|
||||
const ids = Array.from(el.querySelectorAll('form')).map(f => f.getAttribute('data-id'));
|
||||
console.log('New order:', ids);
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: ids })
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initSortable('experience-list', '/admin/experience/reorder');
|
||||
initSortable('education-list', '/admin/education/reorder');
|
||||
initSortable('project-list', '/admin/project/reorder');
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', (evt) => {
|
||||
initSortable('experience-list', '/admin/experience/reorder');
|
||||
initSortable('education-list', '/admin/education/reorder');
|
||||
initSortable('project-list', '/admin/project/reorder');
|
||||
});
|
||||
`}
|
||||
</script>
|
||||
</div>
|
||||
);
|
||||
|
||||
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()
|
||||
.use(cookie())
|
||||
.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 } }) => {
|
||||
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
|
||||
maxAge: 600
|
||||
});
|
||||
|
||||
oauth_code_verifier.set({
|
||||
@@ -96,12 +113,11 @@ export const adminRoutes = new Elysia()
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: true,
|
||||
maxAge: 600 // 10 min
|
||||
maxAge: 600
|
||||
});
|
||||
|
||||
try {
|
||||
const url = keycloak.createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
|
||||
|
||||
return html(
|
||||
<BaseHtml>
|
||||
<div class="flex h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
@@ -131,23 +147,15 @@ export const adminRoutes = new Elysia()
|
||||
}
|
||||
|
||||
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
|
||||
value: tokens.accessToken(),
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 86400 // 1 day
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
oauth_state.remove();
|
||||
oauth_code_verifier.remove();
|
||||
|
||||
@@ -160,7 +168,6 @@ export const adminRoutes = new Elysia()
|
||||
|
||||
.get("/admin/logout", ({ cookie: { auth_session }, set }) => {
|
||||
auth_session.remove();
|
||||
// Optional: Redirect to Keycloak logout endpoint as well
|
||||
return Response.redirect("/admin/login");
|
||||
})
|
||||
|
||||
@@ -169,11 +176,12 @@ export const adminRoutes = new Elysia()
|
||||
if (!isLoggedIn) return Response.redirect("/admin/login");
|
||||
})
|
||||
|
||||
// ... Dashboard and POST handlers remain the same ...
|
||||
// Dashboard
|
||||
.get("/admin/dashboard", ({ html }) => {
|
||||
const profile = getAdminProfile();
|
||||
const experience = getAdminExperience();
|
||||
const education = getAdminEducation();
|
||||
const projects = getAdminProjects();
|
||||
|
||||
return html(
|
||||
<BaseHtml>
|
||||
@@ -213,41 +221,28 @@ export const adminRoutes = new Elysia()
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Projects Section */}
|
||||
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold">Projects</h2>
|
||||
<button hx-post="/admin/project/new" hx-target="#project-list" hx-swap="beforeend" class="bg-orange-500 text-white px-3 py-1 rounded text-sm hover:bg-orange-600">+ Add Project</button>
|
||||
</div>
|
||||
<div id="project-list" class="">
|
||||
{projects.map((proj: any) => (
|
||||
<ProjectForm proj={proj} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Experience Section */}
|
||||
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold">Experience</h2>
|
||||
<form action="/admin/experience/new" method="POST">
|
||||
<button class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">+ Add Job</button>
|
||||
</form>
|
||||
<button hx-post="/admin/experience/new" hx-target="#experience-list" hx-swap="beforeend" class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">+ Add Job</button>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div id="experience-list" class="">
|
||||
{experience.map((exp: any) => (
|
||||
<form action={`/admin/experience/${exp.id}`} method="POST" class="border dark:border-gray-700 p-4 rounded">
|
||||
<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>
|
||||
<ExperienceForm exp={exp} />
|
||||
))}
|
||||
</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="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold">Education</h2>
|
||||
<form action="/admin/education/new" method="POST">
|
||||
<button class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">+ Add Education</button>
|
||||
</form>
|
||||
<button hx-post="/admin/education/new" hx-target="#education-list" hx-swap="beforeend" class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">+ Add Education</button>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div id="education-list" class="">
|
||||
{education.map((edu: any) => (
|
||||
<form action={`/admin/education/${edu.id}`} method="POST" class="border dark:border-gray-700 p-4 rounded">
|
||||
<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>
|
||||
<EducationForm edu={edu} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -295,32 +264,90 @@ export const adminRoutes = new Elysia()
|
||||
</BaseHtml>
|
||||
);
|
||||
})
|
||||
// Handlers
|
||||
|
||||
// POST Handlers
|
||||
.post("/admin/profile", ({ body, set }) => {
|
||||
updateProfile(1, body);
|
||||
return Response.redirect("/admin/dashboard");
|
||||
})
|
||||
|
||||
// Projects
|
||||
.post("/admin/project/new", ({ set }) => {
|
||||
const id = createProject();
|
||||
const newItem = getAdminProjectById(Number(id));
|
||||
if (!newItem) return "";
|
||||
return <ProjectForm proj={newItem} />;
|
||||
})
|
||||
|
||||
.post("/admin/project/reorder", ({ body }) => {
|
||||
const { ids } = body as { ids: string[] };
|
||||
updateProjectOrder(ids.map(Number));
|
||||
return "OK";
|
||||
})
|
||||
|
||||
.post("/admin/project/:id", ({ params, body, set }) => {
|
||||
const id = Number(params.id);
|
||||
updateProject(id, body);
|
||||
const updatedProj = getAdminProjectById(id);
|
||||
if (!updatedProj) return "";
|
||||
return <ProjectForm proj={updatedProj} />;
|
||||
})
|
||||
|
||||
.post("/admin/project/:id/delete", ({ params, set }) => {
|
||||
deleteProject(Number(params.id));
|
||||
return "";
|
||||
})
|
||||
|
||||
// Experience
|
||||
.post("/admin/experience/new", ({ set }) => {
|
||||
createExperience();
|
||||
return Response.redirect("/admin/dashboard");
|
||||
const id = createExperience();
|
||||
const newItem = getAdminExperienceById(Number(id));
|
||||
if (!newItem) return "";
|
||||
return <ExperienceForm exp={newItem} />;
|
||||
})
|
||||
|
||||
.post("/admin/experience/reorder", ({ body }) => {
|
||||
const { ids } = body as { ids: string[] };
|
||||
updateExperienceOrder(ids.map(Number));
|
||||
return "OK";
|
||||
})
|
||||
|
||||
.post("/admin/experience/:id", ({ params, body, set }) => {
|
||||
updateExperience(Number(params.id), body);
|
||||
return Response.redirect("/admin/dashboard");
|
||||
const id = Number(params.id);
|
||||
updateExperience(id, body);
|
||||
const updatedExp = getAdminExperienceById(id);
|
||||
if (!updatedExp) return "";
|
||||
return <ExperienceForm exp={updatedExp} />;
|
||||
})
|
||||
|
||||
.post("/admin/experience/:id/delete", ({ params, set }) => {
|
||||
deleteExperience(Number(params.id));
|
||||
return Response.redirect("/admin/dashboard");
|
||||
return "";
|
||||
})
|
||||
.post("/admin/education/new", ({ set }) => {
|
||||
createEducation();
|
||||
return Response.redirect("/admin/dashboard");
|
||||
|
||||
// Education
|
||||
.post("/admin/education/new", ({ set }) => {
|
||||
const id = createEducation();
|
||||
const newItem = getAdminEducationById(Number(id));
|
||||
if (!newItem) return "";
|
||||
return <EducationForm edu={newItem} />;
|
||||
})
|
||||
|
||||
.post("/admin/education/reorder", ({ body }) => {
|
||||
const { ids } = body as { ids: string[] };
|
||||
updateEducationOrder(ids.map(Number));
|
||||
return "OK";
|
||||
})
|
||||
|
||||
.post("/admin/education/:id", ({ params, body, set }) => {
|
||||
updateEducation(Number(params.id), body);
|
||||
return Response.redirect("/admin/dashboard");
|
||||
const id = Number(params.id);
|
||||
updateEducation(id, body);
|
||||
const updatedEdu = getAdminEducationById(id);
|
||||
if (!updatedEdu) return "";
|
||||
return <EducationForm edu={updatedEdu} />;
|
||||
})
|
||||
|
||||
.post("/admin/education/:id/delete", ({ params, set }) => {
|
||||
deleteEducation(Number(params.id));
|
||||
return Response.redirect("/admin/dashboard");
|
||||
return "";
|
||||
});
|
||||
283
src/routes/pdf.tsx
Normal file
283
src/routes/pdf.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { html } from "@elysiajs/html";
|
||||
import * as elements from "typed-html";
|
||||
import puppeteer from "puppeteer";
|
||||
import { getAllData } from "../db/queries";
|
||||
|
||||
const PdfLayout = ({ children }: elements.Children) => `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>CV</title>
|
||||
<style>
|
||||
/* Color Palette (Navy Theme) */
|
||||
:root {
|
||||
--primary: #2c3e50; /* Deep Navy */
|
||||
--accent: #3498db; /* Brighter Blue for links/highlights */
|
||||
--text: #333333;
|
||||
--text-light: #666666;
|
||||
--border: #e0e0e0;
|
||||
--bg-header: #f8f9fa;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: white;
|
||||
color: var(--text);
|
||||
font-family: 'Arial', 'Helvetica', sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
.page {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
padding: 0; /* Reset padding for header bg */
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header - Full Width with Background */
|
||||
header {
|
||||
background-color: var(--bg-header);
|
||||
border-bottom: 2px solid var(--primary);
|
||||
padding: 15mm 20mm 10mm 20mm; /* Restore padding here */
|
||||
margin-bottom: 1.5em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.header-left h1 {
|
||||
font-size: 26pt;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
line-height: 1;
|
||||
color: var(--primary);
|
||||
}
|
||||
.header-left h2 {
|
||||
font-size: 12pt;
|
||||
font-weight: 400;
|
||||
color: var(--accent);
|
||||
margin: 0.5em 0 0 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.header-right {
|
||||
text-align: right;
|
||||
font-size: 9pt;
|
||||
color: var(--text-light);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.header-right a { color: var(--text-light); text-decoration: none; }
|
||||
|
||||
/* Content Wrapper */
|
||||
.content-wrapper {
|
||||
padding: 0 20mm 15mm 20mm;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Two-Column Grid */
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 65% 35%;
|
||||
gap: 2em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.main-column {
|
||||
padding-right: 1em;
|
||||
}
|
||||
.sidebar-column {
|
||||
padding-left: 1em;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
section { margin-bottom: 1.5em; break-inside: avoid; }
|
||||
.section-title {
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 0.8em;
|
||||
padding-top: 0.5em;
|
||||
padding-bottom: 0.2em;
|
||||
letter-spacing: 1px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Job Entries */
|
||||
.entry { margin-bottom: 1.2em; break-inside: avoid; }
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
.entry-title { font-weight: 700; font-size: 10.5pt; color: var(--primary); }
|
||||
.entry-subtitle { font-weight: 600; color: #444; font-size: 10pt; }
|
||||
.entry-dates { font-size: 9pt; color: var(--accent); font-style: italic; white-space: nowrap; }
|
||||
.entry-description { margin-top: 0.3em; font-size: 9.5pt; color: var(--text); text-align: justify; word-break: break-word; }
|
||||
|
||||
/* Projects */
|
||||
.project-entry { margin-bottom: 0.8em; break-inside: avoid; }
|
||||
.project-title { font-weight: 700; font-size: 10pt; color: var(--primary); }
|
||||
.project-link-compact { font-size: 8.5pt; color: var(--accent); text-decoration: none; white-space: nowrap; }
|
||||
|
||||
/* Sidebar Specifics */
|
||||
.sidebar-column .entry-title { font-size: 10pt; }
|
||||
|
||||
a { text-decoration: none; color: inherit; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
${children}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
export const pdfRoutes = new Elysia()
|
||||
.use(html())
|
||||
.get("/cv/pdf-template", ({ query }) => {
|
||||
const lang = (query.lang as string) || "en";
|
||||
const data = getAllData(lang);
|
||||
if (!data.profile) return "Profile not found";
|
||||
|
||||
const formatDateRange = (start: string | null, end: string | null) => {
|
||||
const startPart = start || '';
|
||||
const endPart = end || "Present";
|
||||
if (startPart && endPart && startPart !== endPart) return `${startPart} – ${endPart}`;
|
||||
if (startPart) return startPart;
|
||||
if (endPart !== "Present") return endPart;
|
||||
return endPart;
|
||||
};
|
||||
|
||||
return (
|
||||
<PdfLayout>
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1>{data.profile.name}</h1>
|
||||
<h2>{data.profile.job_title}</h2>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{data.profile.location && <div>{data.profile.location}</div>}
|
||||
{data.profile.email && <div><a href={`mailto:${data.profile.email}`}>{data.profile.email}</a></div>}
|
||||
{data.profile.phone && <div>{data.profile.phone}</div>}
|
||||
{data.profile.website && <div><a href={data.profile.website}>{data.profile.website.replace(/^https?:\/\//, '')}</a></div>}
|
||||
{data.profile.linkedin_url && <div><a href={data.profile.linkedin_url}>LinkedIn</a></div>}
|
||||
{data.profile.github_url && <div><a href={data.profile.github_url}>GitHub</a></div>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="main-column">
|
||||
<section>
|
||||
<div class="section-title">Summary</div>
|
||||
<p class="entry-description">{data.profile.summary}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Experience</div>
|
||||
{data.experience.map((exp: any) => (
|
||||
<div class="entry">
|
||||
<div class="entry-header">
|
||||
<span class="entry-title">{exp.role}</span>
|
||||
<span class="entry-dates">{formatDateRange(exp.start_date, exp.end_date)}</span>
|
||||
</div>
|
||||
<div class="entry-subtitle">{exp.company_name}</div>
|
||||
{exp.description && <div class="entry-description">{exp.description}</div>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{data.projects && data.projects.length > 0 && (
|
||||
<section>
|
||||
<div class="section-title">Projects</div>
|
||||
{data.projects.map((proj: any) => (
|
||||
<div class="project-entry">
|
||||
<div class="project-header">
|
||||
<span class="project-title">{proj.name}</span>
|
||||
{proj.project_url && <a href={proj.project_url} class="project-link-compact"> ({proj.project_url.replace(/^https?:\/\//, '')})</a>}
|
||||
</div>
|
||||
<div class="project-description">{proj.description}</div>
|
||||
{proj.tech_stack && <div class="project-tech-stack">{proj.tech_stack}</div>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="sidebar-column">
|
||||
<section>
|
||||
<div class="section-title">Education</div>
|
||||
{data.education.map((edu: any) => (
|
||||
<div class="entry">
|
||||
<div class="entry-header" style="flex-direction: column; align-items: flex-start;">
|
||||
<span class="entry-title">{edu.degree}</span>
|
||||
<span class="entry-dates" style="font-size: 8.5pt; margin-bottom: 0.1em;">{formatDateRange(edu.start_date, edu.end_date)}</span>
|
||||
</div>
|
||||
<div class="entry-subtitle">{edu.institution}</div>
|
||||
{edu.description && <div class="entry-description">{edu.description}</div>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">Skills</div>
|
||||
<div class="skills-list">
|
||||
{data.skills.map((skill: any) => (
|
||||
<span class="skill-item">• {skill.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PdfLayout>
|
||||
);
|
||||
})
|
||||
.get("/cv/download", async ({ query, set }) => {
|
||||
const lang = (query.lang as string) || "en";
|
||||
const url = `http://localhost:3000/cv/pdf-template?lang=${lang}`;
|
||||
|
||||
try {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.goto(url, { waitUntil: 'networkidle0' });
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '0', right: '0', bottom: '0', left: '0' }
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
set.headers["Content-Type"] = "application/pdf";
|
||||
set.headers["Content-Disposition"] = `attachment; filename="cv-${lang}.pdf"`;
|
||||
|
||||
return new Response(pdfBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="cv-${lang}.pdf"`
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("PDF Generation Error:", error);
|
||||
set.status = 500;
|
||||
return "Failed to generate PDF";
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,7 @@ 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 { HeroSection, AboutSection, ExperienceSection, EducationSection, SkillsSection, ProjectsSection } from "../components/Sections";
|
||||
import { getAllData } from "../db/queries";
|
||||
|
||||
export const publicRoutes = new Elysia()
|
||||
@@ -41,6 +41,11 @@ export const publicRoutes = new Elysia()
|
||||
{(data.education && data.education.length > 0) ? (
|
||||
<EducationSection education={data.education} />
|
||||
) : ""}
|
||||
|
||||
{/* Projects Section */}
|
||||
{(data.projects && data.projects.length > 0) ? (
|
||||
<ProjectsSection projects={data.projects} />
|
||||
) : ""}
|
||||
|
||||
<SkillsSection skills={data.skills} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user