Initial commit: CV website with ElysiaJS, SQLite, and TailwindCSS

This commit is contained in:
Tuan-Dat Tran
2025-11-21 20:07:05 +01:00
parent 378487efc8
commit 88aeaa9002
14 changed files with 759 additions and 109 deletions

View File

@@ -0,0 +1,41 @@
import * as elements from "typed-html";
export const BaseHtml = ({ children }: elements.Children) => `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<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>
</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors duration-300">
${children}
</body>
</html>
`;

38
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,38 @@
import * as elements from "typed-html";
interface LayoutProps extends elements.Children {
lang: "en" | "de";
}
export const Layout = ({ children, lang }: LayoutProps) => {
return (
<div class="container mx-auto p-4 max-w-4xl">
<header class="flex justify-between items-center py-4 mb-8">
<div class="text-xl font-bold">
<a href={`/${lang}`} class="hover:text-blue-500 dark:hover:text-blue-400">
My CV
</a>
</div>
<nav class="flex space-x-4 items-center">
<a href="/en" class={lang === "en" ? "font-bold text-blue-600 dark:text-blue-300" : "text-gray-600 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400"}>EN</a>
<a href="/de" class={lang === "de" ? "font-bold text-blue-600 dark:text-blue-300" : "text-gray-600 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400"}>DE</a>
<button onclick="toggleTheme()" class="p-2 rounded-md bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white transition-colors duration-200">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
{/* Sun icon */}
<path class="block dark:hidden" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 00-.707-.293h-.001c-.264 0-.524.103-.717.293l-.707.707a1 1 0 101.414 1.414l.707-.707c.2-.193.293-.453.293-.717v-.001zm-10.607.707l-.707-.707a1 1 0 00-1.414 1.414l.707.707c.193.2.453.293.717.293h.001a1 1 0 00.707-1.414zm-.707 4.95a1 1 0 000 1.414l.707.707a1 1 0 101.414-1.414l-.707-.707a1 1 0 00-1.414 0zM10 15a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zm6.929-7.778l.707-.707a1 1 0 00-1.414-1.414l-.707.707a1 1 0 001.414 1.414zM3.071 5.222l.707-.707a1 1 0 00-1.414-1.414l-.707.707a1 1 0 001.414 1.414z" />
{/* Moon icon */}
<path class="hidden dark:block" d="M17.293 13.293A8 8 0 016.707 6.707a8.001 8.001 0 1010.586 6.586z" />
</svg>
</button>
</nav>
</header>
<main>
{children}
</main>
<footer class="text-center py-8 mt-12 text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
<p>&copy; {new Date().getFullYear()} John Doe. All rights reserved.</p>
<p>Built with <a href="https://elysiajs.com/" target="_blank" class="text-blue-500 hover:underline">ElysiaJS</a> & <a href="https://tailwindcss.com/" target="_blank" class="text-blue-500 hover:underline">TailwindCSS</a>.</p>
</footer>
</div>
);
};

122
src/components/Sections.tsx Normal file
View File

@@ -0,0 +1,122 @@
import * as elements from "typed-html";
export const HeroSection = ({ profile }: any) => (
<section id="hero" class="text-center py-20 animate-fade-in relative overflow-hidden">
{/* Decorative background element */}
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[500px] bg-blue-100/50 dark:bg-blue-900/20 blur-[100px] rounded-full -z-10"></div>
<div class="relative inline-block mb-6 group">
<img
src={profile.avatar_url}
alt={profile.name}
class="w-40 h-40 rounded-full object-cover border-4 border-white dark:border-gray-800 shadow-xl transition-transform duration-500 group-hover:scale-105"
/>
<div class="absolute inset-0 rounded-full ring-2 ring-blue-500/20 dark:ring-blue-400/20 group-hover:ring-4 transition-all duration-500"></div>
</div>
<h1 class="text-6xl font-black mb-3 text-gray-900 dark:text-white tracking-tight">
{profile.name}
</h1>
<h2 class="text-2xl font-medium text-blue-600 dark:text-blue-400 mb-2">
{profile.job_title}
</h2>
{profile.location && (
<p class="text-sm text-gray-500 dark:text-gray-400 flex justify-center items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
{profile.location}
</p>
)}
<div class="mt-8 flex justify-center gap-4">
{profile.github_url && <a href={profile.github_url} target="_blank" class="p-2 text-gray-600 hover:text-black dark:text-gray-400 dark:hover:text-white transition-colors"><svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg></a>}
{profile.email && <a href={`mailto:${profile.email}`} class="p-2 text-gray-600 hover:text-black dark:text-gray-400 dark:hover:text-white transition-colors"><svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg></a>}
</div>
</section>
);
export const AboutSection = ({ summary }: { summary: string }) => (
<section id="about" class="py-12 max-w-2xl mx-auto">
<div class="bg-white dark:bg-gray-800/50 backdrop-blur-sm rounded-2xl p-8 shadow-sm border border-gray-100 dark:border-gray-700/50">
<h3 class="text-sm uppercase tracking-widest text-gray-500 dark:text-gray-400 font-bold mb-4">About</h3>
<p class="text-lg text-gray-700 dark:text-gray-300 leading-relaxed">
{summary}
</p>
</div>
</section>
);
export const ExperienceSection = ({ experience }: any) => (
<section id="experience" class="py-16">
<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-blue-500 rounded-full"></span>
Experience
</h2>
<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">
{/* Hover highlight on line - optional subtle effect */}
<div class="absolute -left-[33px] top-2 w-3 h-3 bg-gray-200 dark:bg-gray-700 rounded-full border-2 border-white dark:border-gray-900 group-hover:bg-blue-500 group-hover:scale-125 transition-all duration-300"></div>
<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"}
</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>
<div class="text-blue-600 dark:text-blue-400 font-medium mb-2">{exp.role}</div>
{exp.description && (
<p class="text-gray-600 dark:text-gray-300 leading-relaxed text-base">
{exp.description}
</p>
)}
</div>
</div>
</div>
))}
</div>
</section>
);
export const EducationSection = ({ education }: any) => (
<section id="education" 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-purple-500 rounded-full"></span>
Education
</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="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>
</div>
<div class="text-purple-600 dark:text-purple-400 font-medium">{edu.degree}</div>
{edu.description && <p class="mt-2 text-gray-600 dark:text-gray-400 text-sm">{edu.description}</p>}
</div>
))}
</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">
<span class="w-8 h-1 bg-green-500 rounded-full"></span>
Skills
</h2>
{/* Grouping by category could be done here if the data supports it nicely,
but for now we'll do a clean tag cloud with categories visually distinct if needed */}
<div class="flex flex-wrap gap-3">
{skills.map((skill: any) => (
<div class="px-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 cursor-default">
<span class="text-xs text-gray-400 block mb-1 uppercase tracking-wider">{skill.category_display || skill.category}</span>
{skill.name}
</div>
))}
</div>
</section>
);

118
src/db/queries.ts Normal file
View File

@@ -0,0 +1,118 @@
import { db } from "./schema";
interface ProfileTranslation {
name: string;
job_title: string;
summary: string | null;
location: string | null;
}
interface Profile extends ProfileTranslation {
email: string;
phone: string | null;
website: string | null;
github_url: string | null;
linkedin_url: string | null;
avatar_url: string | null;
}
interface ExperienceTranslation {
company_name: string;
role: string;
description: string | null;
location: string | null;
}
interface Experience extends ExperienceTranslation {
start_date: string;
end_date: string | null;
company_url: string | null;
}
interface SkillTranslation {
name: string;
category_display: string | null;
}
interface Skill extends SkillTranslation {
category: string;
icon: 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,
pt.name, pt.job_title, pt.summary, pt.location
FROM profile p
JOIN profile_translations pt ON p.id = pt.profile_id
WHERE pt.language_code = $lang
`).get({ $lang: lang }) as Profile | null;
return profile;
}
export function getExperience(lang: string): Experience[] {
const experience = db.query(`
SELECT 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
`).all({ $lang: lang }) as Experience[];
return experience;
}
export function getSkills(lang: string): Skill[] {
const skills = db.query(`
SELECT s.category, s.icon, s.display_order,
st.name, st.category_display
FROM skills s
JOIN skill_translations st ON s.id = st.skill_id
WHERE st.language_code = $lang
ORDER BY s.display_order ASC, s.category ASC, st.name ASC
`).all({ $lang: lang }) as Skill[];
return skills;
}
export function getEducation(lang: string): Education[] {
const education = db.query(`
SELECT e.start_date, e.end_date, e.institution_url, e.display_order,
et.institution, et.degree, et.description
FROM education e
JOIN education_translations et ON e.id = et.education_id
WHERE et.language_code = $lang
ORDER BY e.display_order ASC, e.start_date DESC
`).all({ $lang: lang }) as Education[];
return education;
}
export function getAllData(lang: string) {
return {
profile: getProfile(lang),
experience: getExperience(lang),
education: getEducation(lang),
skills: getSkills(lang),
};
}

121
src/db/schema.ts Normal file
View File

@@ -0,0 +1,121 @@
import { Database } from "bun:sqlite";
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 };

86
src/db/seed.ts Normal file
View File

@@ -0,0 +1,86 @@
import { db, initDB } from "./schema";
export function seedDB() {
// Initialize tables first
initDB();
// Check if data exists to avoid duplicates
const check = db.query("SELECT count(*) as count FROM languages").get() as { count: number };
if (check.count > 0) {
console.log("Database already seeded.");
return;
}
console.log("Seeding database...");
// 1. Languages
const insertLang = db.prepare("INSERT INTO languages (code) VALUES ($code)");
insertLang.run({ $code: "en" });
insertLang.run({ $code: "de" });
// 2. Profile
db.run(`
INSERT INTO profile (id, email, phone, website, github_url, linkedin_url, avatar_url)
VALUES (1, 'contact@johndoe.dev', '+49 123 456789', 'https://johndoe.dev', 'https://github.com/johndoe', 'https://linkedin.com/in/johndoe', 'https://placehold.co/400')
`);
const insertProfileTrans = db.prepare(`
INSERT INTO profile_translations (profile_id, language_code, name, job_title, summary, location)
VALUES ($pid, $code, $name, $title, $summary, $loc)
`);
insertProfileTrans.run({
$pid: 1, $code: "en", $name: "John Doe", $title: "Full Stack Developer",
$summary: "Passionate developer building minimalist and high-performance web applications.",
$loc: "Berlin, Germany"
});
insertProfileTrans.run({
$pid: 1, $code: "de", $name: "John Doe", $title: "Full Stack Entwickler",
$summary: "Leidenschaftlicher Entwickler für minimalistische und hochperformante Webanwendungen.",
$loc: "Berlin, Deutschland"
});
// 3. Experience
const insertExp = db.prepare(`
INSERT INTO experience (start_date, end_date, company_url, display_order)
VALUES ($start, $end, $url, $order)
RETURNING id
`);
// Job 1
const job1 = insertExp.get({ $start: "2023-01", $end: null, $url: "https://techcorp.com", $order: 1 }) as { id: number };
const insertExpTrans = db.prepare(`
INSERT INTO experience_translations (experience_id, language_code, company_name, role, description)
VALUES ($eid, $code, $comp, $role, $desc)
`);
insertExpTrans.run({
$eid: job1.id, $code: "en", $comp: "Tech Corp", $role: "Senior Engineer",
$desc: "Leading the frontend team and migrating legacy codebase to ElysiaJS."
});
insertExpTrans.run({
$eid: job1.id, $code: "de", $comp: "Tech Corp", $role: "Senior Entwickler",
$desc: "Leitung des Frontend-Teams und Migration der Legacy-Codebasis zu ElysiaJS."
});
// 4. Skills
const insertSkill = db.prepare(`
INSERT INTO skills (category, icon, display_order) VALUES ($cat, $icon, $order) RETURNING id
`);
const insertSkillTrans = db.prepare(`
INSERT INTO skill_translations (skill_id, language_code, name, category_display)
VALUES ($sid, $code, $name, $catDisplay)
`);
const s1 = insertSkill.get({ $cat: "tech", $icon: "code", $order: 1 }) as { id: number };
insertSkillTrans.run({ $sid: s1.id, $code: "en", $name: "TypeScript", $catDisplay: "Technologies" });
insertSkillTrans.run({ $sid: s1.id, $code: "de", $name: "TypeScript", $catDisplay: "Technologien" });
console.log("Seeding complete.");
}
// Allow running directly: bun src/db/seed.ts
if (import.meta.main) {
seedDB();
}

View File

@@ -1,7 +0,0 @@
import { Elysia } from "elysia";
const app = new Elysia().get("/", () => "Hello Elysia").listen(3000);
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);

51
src/index.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { Elysia, NotFoundError } from "elysia";
import { html } from "@elysiajs/html";
import * as elements from "typed-html";
import { BaseHtml } from "./components/BaseHtml";
import { Layout } from "./components/Layout";
import { HeroSection, AboutSection, ExperienceSection, EducationSection, SkillsSection } from "./components/Sections";
import { getAllData } from "./db/queries";
const app = new Elysia()
.use(html())
.get("/", () => {
return Response.redirect("/en"); // Default to English
})
.get("/:lang", ({ params, html, set }) => {
const lang = params.lang as "en" | "de";
if (!["en", "de"].includes(lang)) {
throw new NotFoundError();
}
const data = getAllData(lang);
if (!data.profile) {
throw new NotFoundError("Profile data not found for selected language.");
}
return html(
<BaseHtml>
<Layout lang={lang}>
<HeroSection profile={data.profile} />
{/* Separate About Section using the summary */}
{data.profile.summary && <AboutSection summary={data.profile.summary} />}
<div class="grid gap-12">
<ExperienceSection experience={data.experience} />
{/* Only render Education section if data exists */}
{data.education && data.education.length > 0 && (
<EducationSection education={data.education} />
)}
<SkillsSection skills={data.skills} />
</div>
</Layout>
</BaseHtml>
);
})
.listen(3000);
console.log(
`Elysia is running at http://${app.server?.hostname}:${app.server?.port}`
);