feat(db): Implement schema migrations and add Projects section
Introduces a custom migration system for SQLite, allowing incremental and safe schema evolution. Adds a new 'Projects' section to the CV, including database tables, public UI, and full management in the admin dashboard with live editing, drag-and-drop reordering, and collapsible forms. Updates: - and for schema management. - with script. - to use migrations. - to rely on migrations. - and for new project data operations. - and for Projects UI. - and to integrate the Projects section. Also updates: - to automatically import Keycloak realm on startup. - for the Elysia app build. - with refined print styles (omitting socials and about).
This commit is contained in:
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" ]
|
||||||
@@ -14,7 +14,9 @@ services:
|
|||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:23.0
|
image: quay.io/keycloak/keycloak:23.0
|
||||||
command: start-dev
|
command: start-dev --import-realm
|
||||||
|
volumes:
|
||||||
|
- ./realm-export.json:/opt/keycloak/data/import/realm.json
|
||||||
environment:
|
environment:
|
||||||
KC_DB: postgres
|
KC_DB: postgres
|
||||||
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
|
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
|
||||||
@@ -22,6 +24,7 @@ services:
|
|||||||
KC_DB_PASSWORD: password
|
KC_DB_PASSWORD: password
|
||||||
KEYCLOAK_ADMIN: admin
|
KEYCLOAK_ADMIN: admin
|
||||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||||
|
KC_HOSTNAME_URL: http://localhost:8080/
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"version": "1.0.50",
|
"version": "1.0.50",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"dev": "bun run --watch src/index.ts"
|
"dev": "bun run --watch src/index.ts",
|
||||||
|
"db:migrate": "bun run src/db/migrator.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/cookie": "^0.8.0",
|
"@elysiajs/cookie": "^0.8.0",
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -123,3 +123,52 @@ export const EducationForm = ({ edu }: any) => (
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
||||||
|
);
|
||||||
|
|||||||
@@ -44,13 +44,148 @@ export const BaseHtml = ({ children }: elements.Children) => `
|
|||||||
opacity: 0.8; /* Slight transparency */
|
opacity: 0.8; /* Slight transparency */
|
||||||
}
|
}
|
||||||
/* Class for the ghost/placeholder when item is dragged */
|
/* Class for the ghost/placeholder when item is dragged */
|
||||||
.sortable-ghost {
|
.sortable-ghost {
|
||||||
background-color: theme('colors.blue.50'); /* Lighter background */
|
background-color: theme('colors.blue.50'); /* Lighter background */
|
||||||
border: 1px dashed theme('colors.blue.300'); /* Dashed border */
|
border: 1px dashed theme('colors.blue.300'); /* Dashed border */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
<style>
|
||||||
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors duration-300" x-data>
|
@media print {
|
||||||
${children}
|
@page {
|
||||||
</body></html>
|
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" x-data>
|
||||||
|
${children}
|
||||||
|
</body></html>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -98,6 +98,37 @@ export const EducationSection = ({ education }: any) => (
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ProjectsSection = ({ projects }: any) => (
|
||||||
|
<section id="projects" class="py-12">
|
||||||
|
<h2 class="text-3xl font-bold mb-12 text-gray-900 dark:text-white flex items-center gap-3">
|
||||||
|
<span class="w-8 h-1 bg-orange-500 rounded-full"></span>
|
||||||
|
Projects
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
{projects.map((proj: any) => (
|
||||||
|
<a href={proj.project_url} target="_blank" class="group block bg-gray-50 dark:bg-gray-800/50 rounded-xl overflow-hidden border border-gray-100 dark:border-gray-700 hover:border-orange-500/50 transition-all hover:shadow-lg hover:-translate-y-1">
|
||||||
|
{proj.image_url && (
|
||||||
|
<div class="h-48 bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||||
|
<img src={proj.image_url} alt={proj.name} class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="p-6">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-orange-600 dark:group-hover:text-orange-400 transition-colors mb-2">{proj.name}</h3>
|
||||||
|
{proj.description && <p class="text-gray-600 dark:text-gray-300 text-sm mb-4 leading-relaxed">{proj.description}</p>}
|
||||||
|
{proj.tech_stack && (
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{proj.tech_stack.split(',').map((tech: string) => (
|
||||||
|
<span class="text-xs font-mono px-2 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md">{tech.trim()}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
export const SkillsSection = ({ skills }: any) => (
|
export const SkillsSection = ({ skills }: any) => (
|
||||||
<section id="skills" class="py-16">
|
<section id="skills" class="py-16">
|
||||||
<h2 class="text-3xl font-bold mb-10 text-gray-900 dark:text-white flex items-center gap-3">
|
<h2 class="text-3xl font-bold mb-10 text-gray-900 dark:text-white flex items-center gap-3">
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
@@ -111,3 +111,44 @@ export function updateEducationOrder(ids: number[]) {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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 });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -56,6 +56,19 @@ interface Skill extends SkillTranslation {
|
|||||||
display_order: number;
|
display_order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectTranslation {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project extends ProjectTranslation {
|
||||||
|
id: number;
|
||||||
|
project_url: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
tech_stack: string | null;
|
||||||
|
display_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function getProfile(lang: string): Profile | null {
|
export function getProfile(lang: string): Profile | null {
|
||||||
const profile = db.query(`
|
const profile = db.query(`
|
||||||
SELECT p.email, p.phone, p.website, p.github_url, p.linkedin_url, p.avatar_url,
|
SELECT p.email, p.phone, p.website, p.github_url, p.linkedin_url, p.avatar_url,
|
||||||
@@ -91,6 +104,18 @@ export function getEducation(lang: string): Education[] {
|
|||||||
return education;
|
return education;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProjects(lang: string): Project[] {
|
||||||
|
const projects = db.query(`
|
||||||
|
SELECT p.id, p.project_url, p.image_url, p.tech_stack, p.display_order,
|
||||||
|
pt.name, pt.description
|
||||||
|
FROM projects p
|
||||||
|
JOIN project_translations pt ON p.id = pt.project_id
|
||||||
|
WHERE pt.language_code = $lang
|
||||||
|
ORDER BY p.display_order ASC
|
||||||
|
`).all({ $lang: lang }) as Project[];
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
export function getSkills(lang: string): Skill[] {
|
export function getSkills(lang: string): Skill[] {
|
||||||
const skills = db.query(`
|
const skills = db.query(`
|
||||||
SELECT s.category, s.icon, s.display_order,
|
SELECT s.category, s.icon, s.display_order,
|
||||||
@@ -108,6 +133,7 @@ export function getAllData(lang: string) {
|
|||||||
profile: getProfile(lang),
|
profile: getProfile(lang),
|
||||||
experience: getExperience(lang),
|
experience: getExperience(lang),
|
||||||
education: getEducation(lang),
|
education: getEducation(lang),
|
||||||
|
projects: getProjects(lang),
|
||||||
skills: getSkills(lang),
|
skills: getSkills(lang),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -180,3 +206,27 @@ export function getAdminEducationById(id: number) {
|
|||||||
});
|
});
|
||||||
return e;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
113
src/db/schema.ts
113
src/db/schema.ts
@@ -5,117 +5,4 @@ const db = new Database("cv.sqlite", { create: true });
|
|||||||
// Enable foreign keys
|
// Enable foreign keys
|
||||||
db.run("PRAGMA foreign_keys = ON;");
|
db.run("PRAGMA foreign_keys = ON;");
|
||||||
|
|
||||||
export function initDB() {
|
|
||||||
// 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() {
|
export function seedDB() {
|
||||||
// Initialize tables first
|
// Run migrations first
|
||||||
initDB();
|
console.log("Running migrations...");
|
||||||
|
const migrate = spawnSync(["bun", "run", "src/db/migrator.ts"]);
|
||||||
|
console.log(migrate.stdout.toString());
|
||||||
|
if (migrate.exitCode !== 0) {
|
||||||
|
console.error("Migration failed:", migrate.stderr.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if data exists to avoid duplicates
|
// Check if data exists to avoid duplicates
|
||||||
const check = db.query("SELECT count(*) as count FROM languages").get() as { count: number };
|
const check = db.query("SELECT count(*) as count FROM languages").get() as { count: number };
|
||||||
@@ -100,6 +107,19 @@ export function seedDB() {
|
|||||||
insertSkillTrans.run({ $sid: s1.id, $code: "en", $name: "TypeScript", $catDisplay: "Technologies" });
|
insertSkillTrans.run({ $sid: s1.id, $code: "en", $name: "TypeScript", $catDisplay: "Technologies" });
|
||||||
insertSkillTrans.run({ $sid: s1.id, $code: "de", $name: "TypeScript", $catDisplay: "Technologien" });
|
insertSkillTrans.run({ $sid: s1.id, $code: "de", $name: "TypeScript", $catDisplay: "Technologien" });
|
||||||
|
|
||||||
|
// 6. Projects
|
||||||
|
const insertProj = db.prepare(`
|
||||||
|
INSERT INTO projects (project_url, image_url, tech_stack, display_order) VALUES ($url, $img, $stack, $order) RETURNING id
|
||||||
|
`);
|
||||||
|
const insertProjTrans = db.prepare(`
|
||||||
|
INSERT INTO project_translations (project_id, language_code, name, description) VALUES ($pid, $code, $name, $desc)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const p1 = insertProj.get({ $url: "https://github.com/example/cv-app", $img: "https://placehold.co/600x400", $stack: "ElysiaJS, SQLite, Tailwind", $order: 1 }) as { id: number };
|
||||||
|
|
||||||
|
insertProjTrans.run({ $pid: p1.id, $code: "en", $name: "CV Website", $desc: "A high-performance SSR portfolio built with Bun." });
|
||||||
|
insertProjTrans.run({ $pid: p1.id, $code: "de", $name: "CV Webseite", $desc: "Ein hochperformantes SSR-Portfolio basierend auf Bun." });
|
||||||
|
|
||||||
console.log("Seeding complete.");
|
console.log("Seeding complete.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { cookie } from "@elysiajs/cookie";
|
|||||||
import { KeyCloak, generateState, generateCodeVerifier } from "arctic";
|
import { KeyCloak, generateState, generateCodeVerifier } from "arctic";
|
||||||
import * as elements from "typed-html";
|
import * as elements from "typed-html";
|
||||||
import { BaseHtml } from "../components/BaseHtml";
|
import { BaseHtml } from "../components/BaseHtml";
|
||||||
import { InputGroup, TextAreaGroup, ExperienceForm, EducationForm } from "../components/AdminForms";
|
import { InputGroup, TextAreaGroup, ExperienceForm, EducationForm, ProjectForm } from "../components/AdminForms";
|
||||||
import { getAdminProfile, getAdminExperience, getAdminEducation, getAdminExperienceById, getAdminEducationById } from "../db/queries";
|
import { getAdminProfile, getAdminExperience, getAdminEducation, getAdminProjects, getAdminExperienceById, getAdminEducationById, getAdminProjectById } from "../db/queries";
|
||||||
import { updateProfile, createExperience, updateExperience, deleteExperience, createEducation, updateEducation, deleteEducation, updateExperienceOrder, updateEducationOrder } from "../db/mutations";
|
import { updateProfile, createExperience, updateExperience, deleteExperience, createEducation, updateEducation, deleteEducation, updateExperienceOrder, updateEducationOrder, createProject, updateProject, deleteProject, updateProjectOrder } from "../db/mutations";
|
||||||
|
|
||||||
// Initialize Keycloak (Arctic)
|
// Initialize Keycloak (Arctic)
|
||||||
const realmURL = process.env.KEYCLOAK_REALM_URL || "";
|
const realmURL = process.env.KEYCLOAK_REALM_URL || "";
|
||||||
@@ -44,14 +44,20 @@ const AdminLayout = ({ children }: elements.Children) => (
|
|||||||
|
|
||||||
if (el._sortable) return;
|
if (el._sortable) return;
|
||||||
|
|
||||||
|
console.log('Initializing Sortable on:', id);
|
||||||
el._sortable = new Sortable(el, {
|
el._sortable = new Sortable(el, {
|
||||||
group: id,
|
group: id,
|
||||||
draggable: 'form',
|
draggable: 'form',
|
||||||
handle: '.drag-handle',
|
handle: '.drag-handle',
|
||||||
animation: 150,
|
animation: 150,
|
||||||
ghostClass: 'bg-blue-100',
|
ghostClass: 'bg-blue-100',
|
||||||
|
onStart: function(evt) {
|
||||||
|
console.log('Drag started', evt);
|
||||||
|
},
|
||||||
onEnd: function (evt) {
|
onEnd: function (evt) {
|
||||||
|
console.log('Drag ended', evt);
|
||||||
const ids = Array.from(el.querySelectorAll('form')).map(f => f.getAttribute('data-id'));
|
const ids = Array.from(el.querySelectorAll('form')).map(f => f.getAttribute('data-id'));
|
||||||
|
console.log('New order:', ids);
|
||||||
fetch(endpoint, {
|
fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -64,11 +70,13 @@ const AdminLayout = ({ children }: elements.Children) => (
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initSortable('experience-list', '/admin/experience/reorder');
|
initSortable('experience-list', '/admin/experience/reorder');
|
||||||
initSortable('education-list', '/admin/education/reorder');
|
initSortable('education-list', '/admin/education/reorder');
|
||||||
|
initSortable('project-list', '/admin/project/reorder');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', (evt) => {
|
document.body.addEventListener('htmx:afterSwap', (evt) => {
|
||||||
initSortable('experience-list', '/admin/experience/reorder');
|
initSortable('experience-list', '/admin/experience/reorder');
|
||||||
initSortable('education-list', '/admin/education/reorder');
|
initSortable('education-list', '/admin/education/reorder');
|
||||||
|
initSortable('project-list', '/admin/project/reorder');
|
||||||
});
|
});
|
||||||
`}
|
`}
|
||||||
</script>
|
</script>
|
||||||
@@ -173,6 +181,7 @@ export const adminRoutes = new Elysia()
|
|||||||
const profile = getAdminProfile();
|
const profile = getAdminProfile();
|
||||||
const experience = getAdminExperience();
|
const experience = getAdminExperience();
|
||||||
const education = getAdminEducation();
|
const education = getAdminEducation();
|
||||||
|
const projects = getAdminProjects();
|
||||||
|
|
||||||
return html(
|
return html(
|
||||||
<BaseHtml>
|
<BaseHtml>
|
||||||
@@ -212,6 +221,19 @@ export const adminRoutes = new Elysia()
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Projects Section */}
|
||||||
|
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-2xl font-bold">Projects</h2>
|
||||||
|
<button hx-post="/admin/project/new" hx-target="#project-list" hx-swap="beforeend" class="bg-orange-500 text-white px-3 py-1 rounded text-sm hover:bg-orange-600">+ Add Project</button>
|
||||||
|
</div>
|
||||||
|
<div id="project-list" class="">
|
||||||
|
{projects.map((proj: any) => (
|
||||||
|
<ProjectForm proj={proj} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Experience Section */}
|
{/* Experience Section */}
|
||||||
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
|
<div class="bg-white dark:bg-gray-800 p-6 rounded shadow mb-8">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
@@ -249,6 +271,34 @@ export const adminRoutes = new Elysia()
|
|||||||
return Response.redirect("/admin/dashboard");
|
return Response.redirect("/admin/dashboard");
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
.post("/admin/project/new", ({ set }) => {
|
||||||
|
const id = createProject();
|
||||||
|
const newItem = getAdminProjectById(Number(id));
|
||||||
|
if (!newItem) return "";
|
||||||
|
return <ProjectForm proj={newItem} />;
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/admin/project/reorder", ({ body }) => {
|
||||||
|
const { ids } = body as { ids: string[] };
|
||||||
|
updateProjectOrder(ids.map(Number));
|
||||||
|
return "OK";
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/admin/project/:id", ({ params, body, set }) => {
|
||||||
|
const id = Number(params.id);
|
||||||
|
updateProject(id, body);
|
||||||
|
const updatedProj = getAdminProjectById(id);
|
||||||
|
if (!updatedProj) return "";
|
||||||
|
return <ProjectForm proj={updatedProj} />;
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/admin/project/:id/delete", ({ params, set }) => {
|
||||||
|
deleteProject(Number(params.id));
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
|
||||||
|
// Experience
|
||||||
.post("/admin/experience/new", ({ set }) => {
|
.post("/admin/experience/new", ({ set }) => {
|
||||||
const id = createExperience();
|
const id = createExperience();
|
||||||
const newItem = getAdminExperienceById(Number(id));
|
const newItem = getAdminExperienceById(Number(id));
|
||||||
@@ -275,6 +325,7 @@ export const adminRoutes = new Elysia()
|
|||||||
return "";
|
return "";
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Education
|
||||||
.post("/admin/education/new", ({ set }) => {
|
.post("/admin/education/new", ({ set }) => {
|
||||||
const id = createEducation();
|
const id = createEducation();
|
||||||
const newItem = getAdminEducationById(Number(id));
|
const newItem = getAdminEducationById(Number(id));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { html } from "@elysiajs/html";
|
|||||||
import * as elements from "typed-html";
|
import * as elements from "typed-html";
|
||||||
import { BaseHtml } from "../components/BaseHtml";
|
import { BaseHtml } from "../components/BaseHtml";
|
||||||
import { Layout } from "../components/Layout";
|
import { Layout } from "../components/Layout";
|
||||||
import { HeroSection, AboutSection, ExperienceSection, EducationSection, SkillsSection } from "../components/Sections";
|
import { HeroSection, AboutSection, ExperienceSection, EducationSection, SkillsSection, ProjectsSection } from "../components/Sections";
|
||||||
import { getAllData } from "../db/queries";
|
import { getAllData } from "../db/queries";
|
||||||
|
|
||||||
export const publicRoutes = new Elysia()
|
export const publicRoutes = new Elysia()
|
||||||
@@ -42,6 +42,11 @@ export const publicRoutes = new Elysia()
|
|||||||
<EducationSection education={data.education} />
|
<EducationSection education={data.education} />
|
||||||
) : ""}
|
) : ""}
|
||||||
|
|
||||||
|
{/* Projects Section */}
|
||||||
|
{(data.projects && data.projects.length > 0) ? (
|
||||||
|
<ProjectsSection projects={data.projects} />
|
||||||
|
) : ""}
|
||||||
|
|
||||||
<SkillsSection skills={data.skills} />
|
<SkillsSection skills={data.skills} />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
Reference in New Issue
Block a user