feat: Implement live editing (HTMX) and refine UI hierarchy
Integrates HTMX for real-time updates of Experience and Education forms in the admin dashboard, eliminating full page reloads on save and enabling instant addition of new entries. Adjusted the visual prominence of 'Role' in Experience and 'Degree' in Education sections. Fixes: - Corrected database query parameter passing, resolving text field clearing issue on save. - Enabled live 'Add New' functionality for Experience and Education entries.
This commit is contained in:
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.
|
||||
75
src/components/AdminForms.tsx
Normal file
75
src/components/AdminForms.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
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" class="border dark:border-gray-700 p-4 rounded mb-6 relative group transition-all duration-300 hover:border-blue-300">
|
||||
<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">
|
||||
{/* 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>
|
||||
|
||||
{/* Success Message Indicator (HTMX can swap this in, but for now just visual feedback on button) */}
|
||||
</form>
|
||||
);
|
||||
|
||||
export const EducationForm = ({ edu }: any) => (
|
||||
<form hx-post={`/admin/education/${edu.id}`} hx-target="this" hx-swap="outerHTML" class="border dark:border-gray-700 p-4 rounded mb-6 relative hover:border-blue-300 transition-colors">
|
||||
<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 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>
|
||||
</form>
|
||||
);
|
||||
@@ -8,6 +8,7 @@ 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 src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
|
||||
@@ -54,7 +54,6 @@ 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">
|
||||
@@ -62,9 +61,9 @@ export const ExperienceSection = ({ experience }: any) => (
|
||||
</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}
|
||||
{exp.role}
|
||||
</h3>
|
||||
<div class="text-blue-600 dark:text-blue-400 font-medium mb-2">{exp.role}</div>
|
||||
<div class="text-blue-600 dark:text-blue-400 font-medium mb-2">{exp.company_name}</div>
|
||||
{exp.description && (
|
||||
<p class="text-gray-600 dark:text-gray-300 leading-relaxed text-base">
|
||||
{exp.description}
|
||||
@@ -88,10 +87,10 @@ export const EducationSection = ({ education }: any) => (
|
||||
{education.map((edu: any) => (
|
||||
<div class="bg-gray-50 dark:bg-gray-800/50 p-6 rounded-xl border border-gray-100 dark:border-gray-700 hover:border-purple-500/30 transition-colors">
|
||||
<div class="flex flex-col md:flex-row md:justify-between md:items-baseline mb-2">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{edu.institution}</h3>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{edu.degree}</h3>
|
||||
<span class="text-sm text-gray-500 font-mono">{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-purple-600 dark:text-purple-400 font-medium">{edu.institution}</div>
|
||||
{edu.description && <p class="mt-2 text-gray-600 dark:text-gray-400 text-sm">{edu.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
@@ -118,4 +117,4 @@ export const SkillsSection = ({ skills }: any) => (
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
);
|
||||
@@ -138,6 +138,20 @@ 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[];
|
||||
return edus.map(e => {
|
||||
@@ -151,4 +165,17 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ 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 { InputGroup, TextAreaGroup, ExperienceForm, EducationForm } from "../components/AdminForms";
|
||||
import { getAdminProfile, getAdminExperience, getAdminEducation, getAdminExperienceById, getAdminEducationById } from "../db/queries";
|
||||
import { updateProfile, createExperience, updateExperience, deleteExperience, createEducation, updateEducation, deleteEducation } from "../db/mutations";
|
||||
|
||||
// Initialize Keycloak (Arctic)
|
||||
// Ensure these env vars are set!
|
||||
const realmURL = process.env.KEYCLOAK_REALM_URL || "";
|
||||
const clientId = process.env.KEYCLOAK_CLIENT_ID || "";
|
||||
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET || "";
|
||||
@@ -18,7 +18,7 @@ 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("Client Secret Length:", clientSecret.length);
|
||||
console.log("-----------------------------");
|
||||
|
||||
const keycloak = new KeyCloak(realmURL, clientId, clientSecret, redirectURI);
|
||||
@@ -41,21 +41,6 @@ const AdminLayout = ({ children }: elements.Children) => (
|
||||
</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 +51,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 +71,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 +105,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 +126,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,7 +134,7 @@ 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();
|
||||
@@ -217,37 +182,12 @@ export const adminRoutes = new Elysia()
|
||||
<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>
|
||||
{/* HTMX Add Button */}
|
||||
<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="space-y-6">
|
||||
{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 +196,12 @@ 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>
|
||||
{/* HTMX Add Button */}
|
||||
<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="space-y-6">
|
||||
{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 +210,60 @@ export const adminRoutes = new Elysia()
|
||||
</BaseHtml>
|
||||
);
|
||||
})
|
||||
// Handlers
|
||||
|
||||
// POST Handlers
|
||||
.post("/admin/profile", ({ body, set }) => {
|
||||
updateProfile(1, body);
|
||||
return Response.redirect("/admin/dashboard");
|
||||
})
|
||||
|
||||
// HTMX Create Experience
|
||||
.post("/admin/experience/new", ({ set }) => {
|
||||
createExperience();
|
||||
return Response.redirect("/admin/dashboard");
|
||||
const id = createExperience(); // Returns number
|
||||
const newItem = getAdminExperienceById(Number(id));
|
||||
if (!newItem) return "";
|
||||
return <ExperienceForm exp={newItem} />;
|
||||
})
|
||||
|
||||
// HTMX Update Experience
|
||||
.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);
|
||||
|
||||
// Fetch fresh data to populate form
|
||||
const updatedExp = getAdminExperienceById(id);
|
||||
if (!updatedExp) return "";
|
||||
|
||||
return <ExperienceForm exp={updatedExp} />;
|
||||
})
|
||||
|
||||
// HTMX Delete Experience
|
||||
.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");
|
||||
|
||||
// HTMX Create Education
|
||||
.post("/admin/education/new", ({ set }) => {
|
||||
const id = createEducation();
|
||||
const newItem = getAdminEducationById(Number(id));
|
||||
if (!newItem) return "";
|
||||
return <EducationForm edu={newItem} />;
|
||||
})
|
||||
|
||||
// HTMX Update Education
|
||||
.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} />;
|
||||
})
|
||||
|
||||
// HTMX Delete Education
|
||||
.post("/admin/education/:id/delete", ({ params, set }) => {
|
||||
deleteEducation(Number(params.id));
|
||||
return Response.redirect("/admin/dashboard");
|
||||
return "";
|
||||
});
|
||||
Reference in New Issue
Block a user