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>
);