This commit is contained in:
161
README.md
161
README.md
@@ -1,162 +1,5 @@
|
|||||||
# Personal Website
|
# Personal Website
|
||||||
|
|
||||||
My personal website built with Astro and Preact!
|
My personal website built with Astro, Vue, and Preact!
|
||||||
|
|
||||||
## Features
|
**Note:** Preact is used just for PDF generation.
|
||||||
|
|
||||||
- **Resume**
|
|
||||||
- **Blog Posts**
|
|
||||||
- **Projects**
|
|
||||||
- **Talks**
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
pnpm i
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Resume Configuration
|
|
||||||
|
|
||||||
The resume system supports multiple sections that can be enabled, disabled, and customized.
|
|
||||||
|
|
||||||
### Available Resume Sections
|
|
||||||
|
|
||||||
| Section | Required Fields |
|
|
||||||
|---------|-----------------|
|
|
||||||
| **basics** | `name`, `email`, `profiles` |
|
|
||||||
| **summary** | `content` |
|
|
||||||
| **experience** | `company`, `position`, `location`, `date`, `description` |
|
|
||||||
| **education** | `institution`, `degree`, `field`, `date` |
|
|
||||||
| **skills** | `name`, `level` (1-5) |
|
|
||||||
| **volunteer** | `organization`, `position`, `date` |
|
|
||||||
| **awards** | `title`, `organization`, `date` |
|
|
||||||
| **profiles** | `network`, `username`, `url` |
|
|
||||||
|
|
||||||
### Section Configuration
|
|
||||||
|
|
||||||
Each section can be configured in `src/config/data.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const resumeConfig: ResumeConfig = {
|
|
||||||
tomlFile: "/files/resume.toml",
|
|
||||||
layout: {
|
|
||||||
leftColumn: ["experience", "volunteer", "awards"],
|
|
||||||
rightColumn: ["skills", "education"],
|
|
||||||
},
|
|
||||||
sections: {
|
|
||||||
summary: {
|
|
||||||
title: "Professional Summary",
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
experience: {
|
|
||||||
title: "Work Experience",
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
awards: {
|
|
||||||
title: "Awards & Recognition",
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
// ... other sections
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Layout Configuration
|
|
||||||
|
|
||||||
The resume layout is fully customizable. You can control which sections appear in which column and their order:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
layout: {
|
|
||||||
leftColumn: ["experience", "volunteer", "awards"],
|
|
||||||
rightColumn: ["skills", "education"],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Available sections for layout:**
|
|
||||||
- `experience` - Work experience
|
|
||||||
- `education` - Educational background
|
|
||||||
- `skills` - Technical and professional skills
|
|
||||||
- `volunteer` - Volunteer work
|
|
||||||
- `awards` - Awards and recognition
|
|
||||||
|
|
||||||
**Layout Rules:**
|
|
||||||
- Sections can be placed in either column
|
|
||||||
- Order within each column is determined by array order
|
|
||||||
- Missing sections are automatically excluded
|
|
||||||
- The `summary` section always appears at the top (full width)
|
|
||||||
- The `profiles` section appears in the header area
|
|
||||||
|
|
||||||
**Example Layout:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
layout: {
|
|
||||||
leftColumn: ["skills", "education"],
|
|
||||||
rightColumn: ["experience", "awards", "volunteer"],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Resume Format (TOML)
|
|
||||||
|
|
||||||
Create a `resume.toml` file in the `public/files/` directory:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[basics]
|
|
||||||
name = "Your Name"
|
|
||||||
email = "your.email@example.com"
|
|
||||||
|
|
||||||
[[basics.profiles]]
|
|
||||||
network = "GitHub"
|
|
||||||
username = "yourusername"
|
|
||||||
url = "https://github.com/yourusername"
|
|
||||||
|
|
||||||
[[basics.profiles]]
|
|
||||||
network = "LinkedIn"
|
|
||||||
username = "yourname"
|
|
||||||
url = "https://linkedin.com/in/yourname"
|
|
||||||
|
|
||||||
[summary]
|
|
||||||
content = "Your professional summary here..."
|
|
||||||
|
|
||||||
[[experience]]
|
|
||||||
company = "Company Name"
|
|
||||||
position = "Job Title"
|
|
||||||
location = "City, State"
|
|
||||||
date = "2020 - Present"
|
|
||||||
description = [
|
|
||||||
"Achievement or responsibility 1",
|
|
||||||
"Achievement or responsibility 2"
|
|
||||||
]
|
|
||||||
url = "https://company.com"
|
|
||||||
|
|
||||||
[[education]]
|
|
||||||
institution = "University Name"
|
|
||||||
degree = "Bachelor of Science"
|
|
||||||
field = "Computer Science"
|
|
||||||
date = "2016 - 2020"
|
|
||||||
details = [
|
|
||||||
"Relevant coursework or achievements"
|
|
||||||
]
|
|
||||||
|
|
||||||
[[skills]]
|
|
||||||
name = "JavaScript"
|
|
||||||
level = 4
|
|
||||||
|
|
||||||
[[skills]]
|
|
||||||
name = "Python"
|
|
||||||
level = 5
|
|
||||||
|
|
||||||
[[volunteer]]
|
|
||||||
organization = "Organization Name"
|
|
||||||
position = "Volunteer Position"
|
|
||||||
date = "2019 - Present"
|
|
||||||
|
|
||||||
[[awards]]
|
|
||||||
title = "Award Title"
|
|
||||||
organization = "Awarding Organization"
|
|
||||||
date = "2023"
|
|
||||||
description = "Brief description of the award"
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from "astro/config";
|
import { defineConfig } from "astro/config";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import preact from "@astrojs/preact";
|
import vue from "@astrojs/vue";
|
||||||
import node from "@astrojs/node";
|
import node from "@astrojs/node";
|
||||||
import icon from "astro-icon";
|
import icon from "astro-icon";
|
||||||
import mdx from "@astrojs/mdx";
|
import mdx from "@astrojs/mdx";
|
||||||
@@ -27,7 +27,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
integrations: [
|
integrations: [
|
||||||
preact(),
|
vue(),
|
||||||
mdx(),
|
mdx(),
|
||||||
icon({
|
icon({
|
||||||
include: {
|
include: {
|
||||||
@@ -55,6 +55,7 @@ export default defineConfig({
|
|||||||
"gitea",
|
"gitea",
|
||||||
"bluesky",
|
"bluesky",
|
||||||
"react",
|
"react",
|
||||||
|
"vue",
|
||||||
"typescript",
|
"typescript",
|
||||||
"astro",
|
"astro",
|
||||||
"go",
|
"go",
|
||||||
@@ -62,7 +63,6 @@ export default defineConfig({
|
|||||||
"dotnet",
|
"dotnet",
|
||||||
"docker",
|
"docker",
|
||||||
"github",
|
"github",
|
||||||
"linkedin",
|
|
||||||
"kotlin",
|
"kotlin",
|
||||||
"swift",
|
"swift",
|
||||||
"flutter",
|
"flutter",
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -10,28 +10,29 @@
|
|||||||
"nix": "nix develop"
|
"nix": "nix develop"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^4.3.13",
|
"@astrojs/mdx": "5.0.0-beta.2",
|
||||||
"@astrojs/node": "^9.5.2",
|
"@astrojs/node": "10.0.0-beta.0",
|
||||||
"@astrojs/preact": "^4.1.3",
|
"@astrojs/rss": "4.0.15",
|
||||||
"@astrojs/rss": "^4.0.15",
|
"@astrojs/vue": "6.0.0-beta.0",
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
"@preact/signals": "^2.5.1",
|
"@mdi/js": "^7.4.47",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"astro": "^5.16.11",
|
"astro": "6.0.0-beta.3",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
"lucide-preact": "^0.562.0",
|
"lucide-vue-next": "^0.563.0",
|
||||||
"preact": "^10.28.2",
|
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.1.18"
|
"simple-icons": "^16.6.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"vue": "^3.5.27"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@catppuccin/daisyui": "^2.1.1",
|
"@catppuccin/daisyui": "^2.1.1",
|
||||||
"@iconify-json/mdi": "^1.2.3",
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
"@iconify-json/simple-icons": "^1.2.66",
|
"@iconify-json/simple-icons": "^1.2.67",
|
||||||
"@types/react": "^19.2.8",
|
"@types/react": "^19.2.9",
|
||||||
"daisyui": "^5.5.14"
|
"daisyui": "^5.5.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1767
pnpm-lock.yaml
generated
1767
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,6 @@ website = "https://atri.dad"
|
|||||||
left_column = ["experience", "volunteer"]
|
left_column = ["experience", "volunteer"]
|
||||||
right_column = ["skills", "education", "awards"]
|
right_column = ["skills", "education", "awards"]
|
||||||
|
|
||||||
[[basics.profiles]]
|
|
||||||
network = "LinkedIn"
|
|
||||||
username = "atridadl"
|
|
||||||
url = "https://www.linkedin.com/in/atridadl/"
|
|
||||||
|
|
||||||
[[basics.profiles]]
|
[[basics.profiles]]
|
||||||
network = "Gitea"
|
network = "Gitea"
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Icon } from "astro-icon/components";
|
|
||||||
import type { IconType, LucideIcon, AstroIconName } from "../types";
|
|
||||||
|
|
||||||
interface IconRendererProps {
|
|
||||||
icon: IconType;
|
|
||||||
size?: number;
|
|
||||||
class?: string;
|
|
||||||
[key: string]: any; // For additional props like client:load for custom components
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type guard functions
|
|
||||||
function isLucideIcon(icon: IconType): icon is LucideIcon {
|
|
||||||
return typeof icon === "function" && icon.length <= 1; // Lucide icons are function components
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAstroIconName(icon: IconType): icon is AstroIconName {
|
|
||||||
return typeof icon === "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function IconRenderer({
|
|
||||||
icon,
|
|
||||||
size,
|
|
||||||
class: className,
|
|
||||||
...props
|
|
||||||
}: IconRendererProps) {
|
|
||||||
if (isLucideIcon(icon)) {
|
|
||||||
const LucideComponent = icon;
|
|
||||||
return <LucideComponent size={size} class={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAstroIconName(icon)) {
|
|
||||||
return <Icon name={icon} class={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof icon === "function") {
|
|
||||||
const CustomComponent = icon;
|
|
||||||
return <CustomComponent class={className} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { useComputed, useSignal } from "@preact/signals";
|
|
||||||
import { useEffect } from "preact/hooks";
|
|
||||||
import { config } from "../config";
|
|
||||||
import type { LucideIcon } from "../types";
|
|
||||||
|
|
||||||
interface NavigationBarProps {
|
|
||||||
currentPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NavigationBar({ currentPath }: NavigationBarProps) {
|
|
||||||
const isScrolling = useSignal(false);
|
|
||||||
const prevScrollPos = useSignal(0);
|
|
||||||
const currentClientPath = useSignal(currentPath);
|
|
||||||
|
|
||||||
const isVisible = useComputed(() => {
|
|
||||||
if (prevScrollPos.value < 50) return true;
|
|
||||||
|
|
||||||
const currentPos = typeof window !== "undefined" ? globalThis.scrollY : 0;
|
|
||||||
return prevScrollPos.value > currentPos;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter out disabled navigation items
|
|
||||||
const enabledNavigationItems = config.navigationItems.filter(
|
|
||||||
(item) => item.enabled !== false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update client path when location changes
|
|
||||||
useEffect(() => {
|
|
||||||
const updatePath = () => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
currentClientPath.value = window.location.pathname;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updatePath();
|
|
||||||
|
|
||||||
document.addEventListener("astro:page-load", updatePath);
|
|
||||||
document.addEventListener("astro:after-swap", updatePath);
|
|
||||||
window.addEventListener("popstate", updatePath);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("astro:page-load", updatePath);
|
|
||||||
document.removeEventListener("astro:after-swap", updatePath);
|
|
||||||
window.removeEventListener("popstate", updatePath);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Use the client path
|
|
||||||
const activePath = currentClientPath.value;
|
|
||||||
|
|
||||||
// Normalize path
|
|
||||||
const normalizedPath =
|
|
||||||
activePath.endsWith("/") && activePath.length > 1
|
|
||||||
? activePath.slice(0, -1)
|
|
||||||
: activePath;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let scrollTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
isScrolling.value = true;
|
|
||||||
prevScrollPos.value = globalThis.scrollY;
|
|
||||||
|
|
||||||
if (scrollTimer) clearTimeout(scrollTimer);
|
|
||||||
|
|
||||||
scrollTimer = setTimeout(() => {
|
|
||||||
isScrolling.value = false;
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.addEventListener("scroll", handleScroll);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
globalThis.removeEventListener("scroll", handleScroll);
|
|
||||||
if (scrollTimer) clearTimeout(scrollTimer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={`fixed bottom-3 sm:bottom-4 left-1/2 transform -translate-x-1/2 z-20 transition-all duration-300 ${
|
|
||||||
isScrolling.value ? "opacity-30" : "opacity-100"
|
|
||||||
} ${isVisible.value ? "translate-y-0" : "translate-y-20"}`}
|
|
||||||
>
|
|
||||||
<div class="overflow-visible">
|
|
||||||
<ul class="menu menu-horizontal bg-base-200 rounded-box border-1 border-solid border-primary p-1.5 sm:p-2 flex flex-nowrap whitespace-nowrap">
|
|
||||||
{enabledNavigationItems.map((item) => {
|
|
||||||
const Icon = item.icon as LucideIcon;
|
|
||||||
const isActive = item.isActive
|
|
||||||
? item.isActive(normalizedPath)
|
|
||||||
: normalizedPath === item.path;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={item.id} class="mx-0.5 sm:mx-1">
|
|
||||||
<a
|
|
||||||
href={item.path}
|
|
||||||
class={`tooltip tooltip-top min-h-[44px] min-w-[44px] inline-flex items-center justify-center ${isActive ? "menu-active" : ""}`}
|
|
||||||
aria-label={item.tooltip}
|
|
||||||
data-tip={item.tooltip}
|
|
||||||
data-astro-prefetch="hover"
|
|
||||||
>
|
|
||||||
<Icon size={18} class="sm:w-5 sm:h-5" />
|
|
||||||
<span class="sr-only">{item.name}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
129
src/components/NavigationBar.vue
Normal file
129
src/components/NavigationBar.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
|
import { config } from "../config";
|
||||||
|
import type { Component } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentPath: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isVisible = ref(true);
|
||||||
|
const isScrolling = ref(false);
|
||||||
|
const currentClientPath = ref(props.currentPath);
|
||||||
|
|
||||||
|
// Filter out disabled navigation items
|
||||||
|
const enabledNavigationItems = config.navigationItems.filter(
|
||||||
|
(item) => item.enabled !== false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const activePath = computed(() => currentClientPath.value);
|
||||||
|
|
||||||
|
// Normalize path
|
||||||
|
const normalizedPath = computed(() => {
|
||||||
|
const path = activePath.value;
|
||||||
|
return path.endsWith("/") && path.length > 1 ? path.slice(0, -1) : path;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePath = () => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
currentClientPath.value = window.location.pathname;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll handling
|
||||||
|
let lastScrollY = 0;
|
||||||
|
let ticking = false;
|
||||||
|
let scrollTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const updateScroll = () => {
|
||||||
|
const currentScrollY = window.scrollY;
|
||||||
|
|
||||||
|
// Always show near top
|
||||||
|
if (currentScrollY < 50) {
|
||||||
|
isVisible.value = true;
|
||||||
|
} else {
|
||||||
|
// Show if scrolling up, hide if scrolling down
|
||||||
|
// Only update if position actually changed to avoid jitter
|
||||||
|
if (Math.abs(currentScrollY - lastScrollY) > 0) {
|
||||||
|
isVisible.value = currentScrollY < lastScrollY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrollY = currentScrollY;
|
||||||
|
ticking = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
isScrolling.value = true;
|
||||||
|
|
||||||
|
if (scrollTimer) clearTimeout(scrollTimer);
|
||||||
|
scrollTimer = setTimeout(() => {
|
||||||
|
isScrolling.value = false;
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
if (!ticking) {
|
||||||
|
window.requestAnimationFrame(updateScroll);
|
||||||
|
ticking = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updatePath();
|
||||||
|
lastScrollY = window.scrollY;
|
||||||
|
|
||||||
|
document.addEventListener("astro:page-load", updatePath);
|
||||||
|
document.addEventListener("astro:after-swap", updatePath);
|
||||||
|
window.addEventListener("popstate", updatePath);
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener("astro:page-load", updatePath);
|
||||||
|
document.removeEventListener("astro:after-swap", updatePath);
|
||||||
|
window.removeEventListener("popstate", updatePath);
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
if (scrollTimer) clearTimeout(scrollTimer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fixed bottom-3 sm:bottom-4 left-1/2 transform -translate-x-1/2 z-20 transition-all duration-300"
|
||||||
|
:class="[
|
||||||
|
isScrolling ? 'opacity-30' : 'opacity-100',
|
||||||
|
isVisible ? 'translate-y-0' : 'translate-y-20',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="overflow-visible">
|
||||||
|
<ul
|
||||||
|
class="menu menu-horizontal bg-base-200 rounded-box border-1 border-solid border-primary p-1.5 sm:p-2 flex flex-nowrap whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="item in enabledNavigationItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="mx-0.5 sm:mx-1"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="item.path"
|
||||||
|
class="tooltip tooltip-top min-h-[44px] min-w-[44px] inline-flex items-center justify-center"
|
||||||
|
:class="{
|
||||||
|
'menu-active': item.isActive
|
||||||
|
? item.isActive(normalizedPath)
|
||||||
|
: normalizedPath === item.path,
|
||||||
|
}"
|
||||||
|
:aria-label="item.tooltip"
|
||||||
|
:data-tip="item.tooltip"
|
||||||
|
data-astro-prefetch="hover"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon as Component"
|
||||||
|
:size="18"
|
||||||
|
class="sm:w-5 sm:h-5"
|
||||||
|
/>
|
||||||
|
<span class="sr-only">{{ item.name }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { useState } from "preact/hooks";
|
|
||||||
|
|
||||||
interface ResumeDownloadButtonProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ResumeDownloadButton({
|
|
||||||
className = "",
|
|
||||||
}: ResumeDownloadButtonProps) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/resume/generate?t=${Date.now()}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to generate PDF: ${response.status} ${response.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
// Create a temporary link element and trigger download
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = "Atridad_Lahiji_Resume.pdf";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error downloading PDF:", err);
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to download PDF");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="text-center mb-6 sm:mb-8">
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={isLoading}
|
|
||||||
class={`btn btn-primary font-bold rounded-full inline-flex items-center gap-2 text-sm sm:text-base ${
|
|
||||||
isLoading
|
|
||||||
? "text-primary border-2 border-primary"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<span class="loading loading-spinner"></span>
|
|
||||||
Generating PDF...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Download Resume"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{error && <div class="mt-2 text-error text-sm">{error}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
62
src/components/ResumeDownloadButton.vue
Normal file
62
src/components/ResumeDownloadButton.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/resume/generate?t=${Date.now()}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to generate PDF: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Create a temporary link element and trigger download
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "Atridad_Lahiji_Resume.pdf";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error downloading PDF:", err);
|
||||||
|
error.value = err instanceof Error ? err.message : "Failed to download PDF";
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="text-center mb-6 sm:mb-8">
|
||||||
|
<button
|
||||||
|
@click="handleDownload"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="btn btn-primary font-bold rounded-full inline-flex items-center gap-2 text-sm sm:text-base"
|
||||||
|
:class="{
|
||||||
|
'text-primary border-2 border-primary': isLoading
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template v-if="isLoading">
|
||||||
|
<span class="loading loading-spinner"></span>
|
||||||
|
Generating PDF...
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Download Resume
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
<div v-if="error" class="mt-2 text-error text-sm">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
import { useState } from "preact/hooks";
|
|
||||||
import { useSignal } from "@preact/signals";
|
|
||||||
import { Settings, X } from "lucide-preact";
|
|
||||||
|
|
||||||
interface ResumeSettingsModalProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ResumeSettingsModal({
|
|
||||||
className = "",
|
|
||||||
}: ResumeSettingsModalProps) {
|
|
||||||
const [tomlContent, setTomlContent] = useState("");
|
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<"upload" | "edit">("upload");
|
|
||||||
const dragActive = useSignal(false);
|
|
||||||
const modalOpen = useSignal(false);
|
|
||||||
|
|
||||||
const openModal = () => {
|
|
||||||
modalOpen.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
modalOpen.value = false;
|
|
||||||
setError(null);
|
|
||||||
setTomlContent("");
|
|
||||||
setActiveTab("upload");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = (file: File) => {
|
|
||||||
if (!file.name.endsWith(".toml")) {
|
|
||||||
setError("Please upload a .toml file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const content = e.target?.result as string;
|
|
||||||
setTomlContent(content);
|
|
||||||
setError(null);
|
|
||||||
setActiveTab("edit");
|
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
|
||||||
setError("Error reading file");
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
dragActive.value = false;
|
|
||||||
|
|
||||||
const files = e.dataTransfer?.files;
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
handleFileUpload(files[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
dragActive.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
dragActive.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileInput = (e: Event) => {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
const files = target.files;
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
handleFileUpload(files[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadTemplate = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/resume/template");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to download template");
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = "resume-template.toml";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch (err) {
|
|
||||||
setError("Failed to download template");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generatePDF = async () => {
|
|
||||||
if (!tomlContent.trim()) {
|
|
||||||
setError("Please provide TOML content");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsGenerating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/resume/generate", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
body: tomlContent,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
errorText || `Failed to generate PDF: ${response.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = "resume.pdf";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error generating PDF:", err);
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to generate PDF");
|
|
||||||
} finally {
|
|
||||||
setIsGenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadTemplate = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/resume/template");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to load template");
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = await response.text();
|
|
||||||
setTomlContent(template);
|
|
||||||
setActiveTab("edit");
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError("Failed to load template");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Floating Settings Button */}
|
|
||||||
<button
|
|
||||||
onClick={openModal}
|
|
||||||
class={`fixed top-4 right-4 z-20 btn btn-secondary hover:btn-primary btn-circle ${className}`}
|
|
||||||
aria-label="Resume Settings"
|
|
||||||
>
|
|
||||||
<Settings class="text-lg" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div class={`modal ${modalOpen.value ? "modal-open" : ""}`}>
|
|
||||||
<div class="modal-box w-11/12 max-w-5xl h-[90vh] flex flex-col relative z-50">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="font-bold text-lg">Resume Generator</h3>
|
|
||||||
<button
|
|
||||||
onClick={closeModal}
|
|
||||||
class="btn btn-circle btn-secondary hover:btn-primary"
|
|
||||||
>
|
|
||||||
<X className="text-lg" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-hidden flex flex-col">
|
|
||||||
<p class="text-base-content/70 mb-4">
|
|
||||||
Create a custom PDF resume from a TOML file. Download the
|
|
||||||
template, edit it with your information, and generate your resume.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div class="flex flex-wrap gap-2 mb-6">
|
|
||||||
<button onClick={downloadTemplate} class="btn btn-primary btn-sm font-bold">
|
|
||||||
Download Template
|
|
||||||
</button>
|
|
||||||
<button onClick={loadTemplate} class="btn btn-secondary btn-sm font-bold">
|
|
||||||
Load Template in Editor
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<div
|
|
||||||
role="tablist"
|
|
||||||
class="inline-flex bg-base-300 border border-base-content/20 rounded-full p-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
class={`px-4 py-2 rounded-full text-sm transition-all duration-200 font-bold ${
|
|
||||||
activeTab === "upload"
|
|
||||||
? "btn btn-primary shadow-sm"
|
|
||||||
: "text-base-content/70 hover:text-base-content hover:bg-base-200"
|
|
||||||
}`}
|
|
||||||
onClick={() => setActiveTab("upload")}
|
|
||||||
>
|
|
||||||
Upload TOML
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
class={`px-4 py-2 rounded-full text-sm font-bold transition-all duration-200 ${
|
|
||||||
activeTab === "edit"
|
|
||||||
? "btn btn-primary shadow-sm"
|
|
||||||
: "text-base-content/70 hover:text-base-content hover:bg-base-200"
|
|
||||||
}`}
|
|
||||||
onClick={() => setActiveTab("edit")}
|
|
||||||
>
|
|
||||||
Edit TOML
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<div class="flex-1 overflow-hidden">
|
|
||||||
{/* Upload Tab */}
|
|
||||||
{activeTab === "upload" && (
|
|
||||||
<div class="h-full">
|
|
||||||
<div
|
|
||||||
class={`border-2 border-dashed rounded-lg p-6 text-center transition-colors h-full flex items-center justify-center ${
|
|
||||||
dragActive.value
|
|
||||||
? "bg-primary/20"
|
|
||||||
: "border-primary"
|
|
||||||
}`}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-lg font-medium">
|
|
||||||
Drop your TOML file here
|
|
||||||
</p>
|
|
||||||
<p class="text-base-content/70">
|
|
||||||
or click below to browse
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".toml"
|
|
||||||
onChange={handleFileInput}
|
|
||||||
class="file-input file-input-primary file-input-sm w-full max-w-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit Tab */}
|
|
||||||
{activeTab === "edit" && (
|
|
||||||
<div class="h-full flex flex-col space-y-2">
|
|
||||||
<div class="label">
|
|
||||||
<span class="label-text font-bold">TOML Content</span>
|
|
||||||
<span class="label-text-alt">
|
|
||||||
Edit your resume data below
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
class="textarea textarea-bordered flex-1 font-mono text-xs resize-none w-full min-h-0"
|
|
||||||
placeholder="Paste your TOML content here or load the template..."
|
|
||||||
value={tomlContent}
|
|
||||||
onInput={(e) =>
|
|
||||||
setTomlContent((e.target as HTMLTextAreaElement).value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Display */}
|
|
||||||
{error && (
|
|
||||||
<div class="alert alert-error mt-4">
|
|
||||||
<span class="text-sm">{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Generate Button */}
|
|
||||||
{tomlContent.trim() && (
|
|
||||||
<div class="mt-4">
|
|
||||||
<button
|
|
||||||
onClick={generatePDF}
|
|
||||||
disabled={isGenerating}
|
|
||||||
class="btn btn-primary btn-sm w-full"
|
|
||||||
>
|
|
||||||
{isGenerating ? (
|
|
||||||
<>
|
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
|
||||||
Generating PDF...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Generate Custom Resume PDF"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop" onClick={closeModal}></div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
317
src/components/ResumeSettingsModal.vue
Normal file
317
src/components/ResumeSettingsModal.vue
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { Settings, X } from "lucide-vue-next";
|
||||||
|
|
||||||
|
const tomlContent = ref("");
|
||||||
|
const isGenerating = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const activeTab = ref<"upload" | "edit">("upload");
|
||||||
|
const dragActive = ref(false);
|
||||||
|
const modalOpen = ref(false);
|
||||||
|
|
||||||
|
const hasContent = computed(() => tomlContent.value.trim().length > 0);
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
modalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modalOpen.value = false;
|
||||||
|
error.value = null;
|
||||||
|
tomlContent.value = "";
|
||||||
|
activeTab.value = "upload";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (file: File) => {
|
||||||
|
if (!file.name.endsWith(".toml")) {
|
||||||
|
error.value = "Please upload a .toml file";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const content = e.target?.result as string;
|
||||||
|
tomlContent.value = content;
|
||||||
|
error.value = null;
|
||||||
|
activeTab.value = "edit";
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
error.value = "Error reading file";
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragActive.value = false;
|
||||||
|
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
handleFileUpload(files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragActive.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragActive.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileInput = (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const files = target.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
handleFileUpload(files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/resume/template");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to download template");
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "resume-template.toml";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = "Failed to download template";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePDF = async () => {
|
||||||
|
if (!hasContent.value) {
|
||||||
|
error.value = "Please provide TOML content";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isGenerating.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/resume/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
body: tomlContent.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
errorText || `Failed to generate PDF: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "resume.pdf";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error generating PDF:", err);
|
||||||
|
error.value =
|
||||||
|
err instanceof Error ? err.message : "Failed to generate PDF";
|
||||||
|
} finally {
|
||||||
|
isGenerating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/resume/template");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to load template");
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await response.text();
|
||||||
|
tomlContent.value = template;
|
||||||
|
activeTab.value = "edit";
|
||||||
|
error.value = null;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = "Failed to load template";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Floating Settings Button -->
|
||||||
|
<button
|
||||||
|
@click="openModal"
|
||||||
|
class="fixed top-4 right-4 z-20 btn btn-secondary hover:btn-primary btn-circle"
|
||||||
|
:class="$attrs.class"
|
||||||
|
aria-label="Resume Settings"
|
||||||
|
>
|
||||||
|
<Settings class="text-lg" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="modal" :class="{ 'modal-open': modalOpen }">
|
||||||
|
<div
|
||||||
|
class="modal-box w-11/12 max-w-5xl h-[90vh] flex flex-col relative z-50"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="font-bold text-lg">Resume Generator</h3>
|
||||||
|
<button
|
||||||
|
@click="closeModal"
|
||||||
|
class="btn btn-circle btn-secondary hover:btn-primary"
|
||||||
|
>
|
||||||
|
<X class="text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-hidden flex flex-col">
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
Create a custom PDF resume from a TOML file. Download the
|
||||||
|
template, edit it with your information, and generate your
|
||||||
|
resume.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-6">
|
||||||
|
<button
|
||||||
|
@click="downloadTemplate"
|
||||||
|
class="btn btn-primary btn-sm font-bold"
|
||||||
|
>
|
||||||
|
Download Template
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="loadTemplate"
|
||||||
|
class="btn btn-secondary btn-sm font-bold"
|
||||||
|
>
|
||||||
|
Load Template in Editor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex justify-center mb-4">
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
class="inline-flex bg-base-300 border border-base-content/20 rounded-full p-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class="px-4 py-2 rounded-full text-sm transition-all duration-200 font-bold"
|
||||||
|
:class="
|
||||||
|
activeTab === 'upload'
|
||||||
|
? 'btn btn-primary shadow-sm'
|
||||||
|
: 'text-base-content/70 hover:text-base-content hover:bg-base-200'
|
||||||
|
"
|
||||||
|
@click="activeTab = 'upload'"
|
||||||
|
>
|
||||||
|
Upload TOML
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class="px-4 py-2 rounded-full text-sm font-bold transition-all duration-200"
|
||||||
|
:class="
|
||||||
|
activeTab === 'edit'
|
||||||
|
? 'btn btn-primary shadow-sm'
|
||||||
|
: 'text-base-content/70 hover:text-base-content hover:bg-base-200'
|
||||||
|
"
|
||||||
|
@click="activeTab = 'edit'"
|
||||||
|
>
|
||||||
|
Edit TOML
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<!-- Upload Tab -->
|
||||||
|
<div v-if="activeTab === 'upload'" class="h-full">
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed rounded-lg p-6 text-center transition-colors h-full flex items-center justify-center"
|
||||||
|
:class="
|
||||||
|
dragActive ? 'bg-primary/20' : 'border-primary'
|
||||||
|
"
|
||||||
|
@drop="handleDrop"
|
||||||
|
@dragover="handleDragOver"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-medium">
|
||||||
|
Drop your TOML file here
|
||||||
|
</p>
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
or click below to browse
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".toml"
|
||||||
|
@change="handleFileInput"
|
||||||
|
class="file-input file-input-primary file-input-sm w-full max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Tab -->
|
||||||
|
<div
|
||||||
|
v-if="activeTab === 'edit'"
|
||||||
|
class="h-full flex flex-col space-y-2"
|
||||||
|
>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text font-bold"
|
||||||
|
>TOML Content</span
|
||||||
|
>
|
||||||
|
<span class="label-text-alt">
|
||||||
|
Edit your resume data below
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered flex-1 font-mono text-xs resize-none w-full min-h-0"
|
||||||
|
placeholder="Paste your TOML content here or load the template..."
|
||||||
|
v-model="tomlContent"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Display -->
|
||||||
|
<div v-if="error" class="alert alert-error mt-4">
|
||||||
|
<span class="text-sm">{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generate Button -->
|
||||||
|
<div v-if="hasContent" class="mt-4">
|
||||||
|
<button
|
||||||
|
@click="generatePDF"
|
||||||
|
:disabled="isGenerating"
|
||||||
|
class="btn btn-primary btn-sm w-full"
|
||||||
|
>
|
||||||
|
<template v-if="isGenerating">
|
||||||
|
<span
|
||||||
|
class="loading loading-spinner loading-xs"
|
||||||
|
></span>
|
||||||
|
Generating PDF...
|
||||||
|
</template>
|
||||||
|
<template v-else> Generate Custom Resume PDF </template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" @click="closeModal"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { useSignal } from "@preact/signals";
|
|
||||||
import { useEffect } from "preact/hooks";
|
|
||||||
|
|
||||||
interface Skill {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
level: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResumeSkillsProps {
|
|
||||||
skills: Skill[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ResumeSkills({ skills }: ResumeSkillsProps) {
|
|
||||||
const animatedLevels = useSignal<{ [key: string]: number }>({});
|
|
||||||
const hasAnimated = useSignal(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting && !hasAnimated.value) {
|
|
||||||
hasAnimated.value = true;
|
|
||||||
skills.forEach((skill) => {
|
|
||||||
animateSkill(skill.id, skill.level);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ threshold: 0.3 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const skillsElement = document.getElementById("skills-section");
|
|
||||||
if (skillsElement) {
|
|
||||||
observer.observe(skillsElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (skillsElement) {
|
|
||||||
observer.unobserve(skillsElement);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [skills]);
|
|
||||||
|
|
||||||
const animateSkill = (skillId: string, targetLevel: number) => {
|
|
||||||
const steps = 60;
|
|
||||||
const increment = targetLevel / steps;
|
|
||||||
let currentStep = 0;
|
|
||||||
|
|
||||||
const animate = () => {
|
|
||||||
if (currentStep <= steps) {
|
|
||||||
const currentValue = Math.min(increment * currentStep, targetLevel);
|
|
||||||
animatedLevels.value = {
|
|
||||||
...animatedLevels.value,
|
|
||||||
[skillId]: currentValue,
|
|
||||||
};
|
|
||||||
currentStep++;
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="skills-section" class="space-y-3 sm:space-y-4">
|
|
||||||
{skills.map((skill) => {
|
|
||||||
const currentLevel = animatedLevels.value[skill.id] || 0;
|
|
||||||
const progressValue = currentLevel * 20;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={skill.id} class="p-1 sm:p-2">
|
|
||||||
<div class="flex justify-between items-center mb-2">
|
|
||||||
<span
|
|
||||||
class="text-sm sm:text-base font-medium truncate pr-2 min-w-0 flex-1"
|
|
||||||
title={skill.name}
|
|
||||||
>
|
|
||||||
{skill.name}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs sm:text-sm text-base-content/70 whitespace-nowrap">
|
|
||||||
{Math.round(currentLevel)}/5
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<progress
|
|
||||||
class="progress progress-primary w-full h-2 sm:h-3 min-h-2 transition-all duration-100 ease-out"
|
|
||||||
value={progressValue}
|
|
||||||
max="100"
|
|
||||||
aria-label={`${skill.name} skill level: ${Math.round(currentLevel)} out of 5`}
|
|
||||||
></progress>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
98
src/components/ResumeSkills.vue
Normal file
98
src/components/ResumeSkills.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
|
||||||
|
interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
skills: Skill[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const skillsSection = ref<HTMLElement | null>(null);
|
||||||
|
const animatedLevels = ref<{ [key: string]: number }>({});
|
||||||
|
const hasAnimated = ref(false);
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
let animationFrameId: number | null = null;
|
||||||
|
|
||||||
|
const animateSkills = () => {
|
||||||
|
const duration = 1000; // 1 second animation duration
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
const animate = (currentTime: number) => {
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
|
props.skills.forEach((skill) => {
|
||||||
|
// Linear interpolation from 0 to target level
|
||||||
|
animatedLevels.value[skill.id] = skill.level * progress;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting && !hasAnimated.value) {
|
||||||
|
hasAnimated.value = true;
|
||||||
|
animateSkills();
|
||||||
|
|
||||||
|
// Stop observing once triggered
|
||||||
|
if (skillsSection.value && observer) {
|
||||||
|
observer.unobserve(skillsSection.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.3 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (skillsSection.value) {
|
||||||
|
observer.observe(skillsSection.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div id="skills-section" ref="skillsSection" class="space-y-3 sm:space-y-4">
|
||||||
|
<div v-for="skill in skills" :key="skill.id" class="p-1 sm:p-2">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<span
|
||||||
|
class="text-sm sm:text-base font-medium truncate pr-2 min-w-0 flex-1"
|
||||||
|
:title="skill.name"
|
||||||
|
>
|
||||||
|
{{ skill.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-xs sm:text-sm text-base-content/70 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ Math.round(animatedLevels[skill.id] || 0) }}/5
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<progress
|
||||||
|
class="progress progress-primary w-full h-2 sm:h-3 min-h-2 transition-all duration-100 ease-out"
|
||||||
|
:value="(animatedLevels[skill.id] || 0) * 20"
|
||||||
|
max="100"
|
||||||
|
:aria-label="`${skill.name} skill level: ${Math.round(animatedLevels[skill.id] || 0)} out of 5`"
|
||||||
|
></progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { useSignal } from "@preact/signals";
|
|
||||||
import { useEffect } from "preact/hooks";
|
|
||||||
import { ArrowUp } from "lucide-preact";
|
|
||||||
|
|
||||||
export default function ScrollUpButton() {
|
|
||||||
const isVisible = useSignal(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkScroll = () => {
|
|
||||||
isVisible.value = window.scrollY > 50;
|
|
||||||
};
|
|
||||||
|
|
||||||
checkScroll();
|
|
||||||
|
|
||||||
window.addEventListener("scroll", checkScroll);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", checkScroll);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scrollToTop = () => {
|
|
||||||
window.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={scrollToTop}
|
|
||||||
class={`fixed bottom-4 right-4 z-20 btn btn-secondary hover:btn-primary
|
|
||||||
btn-circle transition-all duration-300
|
|
||||||
${
|
|
||||||
isVisible.value
|
|
||||||
? "opacity-100 translate-y-0"
|
|
||||||
: "opacity-0 translate-y-10 pointer-events-none"
|
|
||||||
}`}
|
|
||||||
aria-label="Scroll to top"
|
|
||||||
>
|
|
||||||
<ArrowUp class="text-lg" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
50
src/components/ScrollUpButton.vue
Normal file
50
src/components/ScrollUpButton.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
import { ArrowUp } from "lucide-vue-next";
|
||||||
|
|
||||||
|
const isVisible = ref(false);
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
const updateScroll = () => {
|
||||||
|
isVisible.value = window.scrollY > 50;
|
||||||
|
ticking = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!ticking) {
|
||||||
|
window.requestAnimationFrame(updateScroll);
|
||||||
|
ticking = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
@click="scrollToTop"
|
||||||
|
class="fixed bottom-4 right-4 z-20 btn btn-secondary hover:btn-primary btn-circle transition-all duration-300"
|
||||||
|
:class="
|
||||||
|
isVisible
|
||||||
|
? 'opacity-100 translate-y-0'
|
||||||
|
: 'opacity-0 translate-y-10 pointer-events-none'
|
||||||
|
"
|
||||||
|
aria-label="Scroll to top"
|
||||||
|
>
|
||||||
|
<ArrowUp class="text-lg" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Config } from "./types";
|
import type { Config } from "./types";
|
||||||
|
|
||||||
import { Home, Newspaper, FileUser, CodeXml, Megaphone } from "lucide-preact";
|
import { Home, Newspaper, FileUser, CodeXml, Megaphone } from "lucide-vue-next";
|
||||||
|
|
||||||
import logo from "./assets/logo.webp";
|
import logo from "./assets/logo.webp";
|
||||||
import resumeToml from "./assets/resume.toml?raw";
|
import resumeToml from "./assets/resume.toml?raw";
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineCollection, z } from 'astro:content';
|
import { defineCollection, z } from 'astro:content';
|
||||||
|
import { glob } from 'astro/loaders';
|
||||||
|
|
||||||
const postsCollection = defineCollection({
|
const posts = defineCollection({
|
||||||
type: 'content',
|
loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: "./src/content/posts" }),
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
@@ -12,5 +13,5 @@ const postsCollection = defineCollection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const collections = {
|
export const collections = {
|
||||||
'posts': postsCollection,
|
posts,
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { ClientRouter } from "astro:transitions";
|
import { ClientRouter } from "astro:transitions";
|
||||||
import NavigationBar from "../components/NavigationBar";
|
import NavigationBar from "../components/NavigationBar.vue";
|
||||||
import ScrollUpButton from "../components/ScrollUpButton";
|
import ScrollUpButton from "../components/ScrollUpButton.vue";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
const currentPath = Astro.url.pathname;
|
const currentPath = Astro.url.pathname;
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
|
|||||||
@@ -3,34 +3,28 @@ import { config } from "../../../config";
|
|||||||
import * as TOML from "@iarna/toml";
|
import * as TOML from "@iarna/toml";
|
||||||
import { renderToStream } from "@react-pdf/renderer";
|
import { renderToStream } from "@react-pdf/renderer";
|
||||||
import { ResumeDocument } from "../../../pdf/ResumeDocument";
|
import { ResumeDocument } from "../../../pdf/ResumeDocument";
|
||||||
|
import { mdiEmail, mdiPhone, mdiDownload, mdiLink } from "@mdi/js";
|
||||||
|
import * as simpleIcons from "simple-icons";
|
||||||
|
|
||||||
async function getSimpleIconPath(iconName: string): Promise<string> {
|
function getSimpleIconPath(network: string): string {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const slug = network.toLowerCase().normalize("NFKD").replace(/[^\w]/g, "");
|
||||||
`https://cdn.jsdelivr.net/npm/simple-icons@v10/icons/${iconName.toLowerCase()}.svg`,
|
const iconKey = `si${slug.charAt(0).toUpperCase()}${slug.slice(1)}`;
|
||||||
);
|
|
||||||
if (!response.ok) {
|
const icon = (simpleIcons as any)[iconKey];
|
||||||
console.warn(`Failed to fetch icon: ${iconName}`);
|
return icon ? icon.path : "";
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const svgContent = await response.text();
|
|
||||||
const match = svgContent.match(/d="([^"]+)"/);
|
|
||||||
return match ? match[1] : "";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Error fetching icon ${iconName}:`, error);
|
console.warn(`Error finding icon for network: ${network}`, error);
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMdiIconPath(iconName: string): string {
|
function getMdiIconPath(iconName: string): string {
|
||||||
const iconMap: { [key: string]: string } = {
|
const iconMap: { [key: string]: string } = {
|
||||||
"mdi:email":
|
"mdi:email": mdiEmail,
|
||||||
"M20,8L12,13L4,8V6L12,11L20,6M20,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6C2.89,4 20,4.89 20,4Z",
|
"mdi:phone": mdiPhone,
|
||||||
"mdi:phone":
|
"mdi:download": mdiDownload,
|
||||||
"M6.62,10.79C8.06,13.62 10.38,15.94 13.21,17.38L15.41,15.18C15.69,14.9 16.08,14.82 16.43,14.93C17.55,15.3 18.75,15.5 20,15.5A1,1 0 0,1 21,16.5V20A1,1 0 0,1 20,21A17,17 0 0,1 3,4A1,1 0 0,1 4,3H7.5A1,1 0 0,1 8.5,4C8.5,5.25 8.7,6.45 9.07,7.57C9.18,7.92 9.1,8.31 8.82,8.59L6.62,10.79Z",
|
"mdi:link": mdiLink,
|
||||||
"mdi:download": "M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z",
|
|
||||||
"mdi:link":
|
|
||||||
"M3.9,12C3.9,10.29 5.29,8.9 7,8.9H11V7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H11V15.1H7C5.29,15.1 3.9,13.71 3.9,12M8,13H16V11H8V13M17,7H13V8.9H17C18.71,8.9 20.1,10.29 20.1,12C20.1,13.71 18.71,15.1 17,15.1H13V17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7Z",
|
|
||||||
};
|
};
|
||||||
return iconMap[iconName] || "";
|
return iconMap[iconName] || "";
|
||||||
}
|
}
|
||||||
@@ -86,12 +80,11 @@ interface ResumeData {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchProfileIcons = async (profiles: any[]) => {
|
const fetchProfileIcons = (profiles: any[]) => {
|
||||||
const profileIcons: { [key: string]: string } = {};
|
const profileIcons: { [key: string]: string } = {};
|
||||||
if (profiles) {
|
if (profiles) {
|
||||||
for (const profile of profiles) {
|
for (const profile of profiles) {
|
||||||
const iconName = profile.network.toLowerCase();
|
profileIcons[profile.network] = getSimpleIconPath(profile.network);
|
||||||
profileIcons[profile.network] = await getSimpleIconPath(iconName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return profileIcons;
|
return profileIcons;
|
||||||
@@ -99,17 +92,15 @@ const fetchProfileIcons = async (profiles: any[]) => {
|
|||||||
|
|
||||||
const generatePDF = async (data: ResumeData) => {
|
const generatePDF = async (data: ResumeData) => {
|
||||||
const resumeConfig = config.resumeConfig;
|
const resumeConfig = config.resumeConfig;
|
||||||
|
const profileIcons = fetchProfileIcons(data.basics.profiles);
|
||||||
const profileIcons = await fetchProfileIcons(data.basics.profiles);
|
|
||||||
const icons = {
|
const icons = {
|
||||||
...profileIcons,
|
...profileIcons,
|
||||||
email: getMdiIconPath("mdi:email"),
|
email: getMdiIconPath("mdi:email"),
|
||||||
phone: getMdiIconPath("mdi:phone"),
|
phone: getMdiIconPath("mdi:phone"),
|
||||||
};
|
};
|
||||||
|
|
||||||
return await renderToStream(
|
return await renderToStream(ResumeDocument({ data, resumeConfig, icons }));
|
||||||
ResumeDocument({ data, resumeConfig, icons })
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
@@ -137,9 +128,9 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resumeData: ResumeData = TOML.parse(
|
const resumeData: ResumeData = TOML.parse(
|
||||||
tomlContent,
|
tomlContent,
|
||||||
) as unknown as ResumeData;
|
) as unknown as ResumeData;
|
||||||
|
|
||||||
const stream = await generatePDF(resumeData);
|
const stream = await generatePDF(resumeData);
|
||||||
|
|
||||||
return new Response(stream as any, {
|
return new Response(stream as any, {
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ network = "GitHub"
|
|||||||
username = "yourusername"
|
username = "yourusername"
|
||||||
url = "https://github.com/yourusername"
|
url = "https://github.com/yourusername"
|
||||||
|
|
||||||
[[basics.profiles]]
|
|
||||||
network = "LinkedIn"
|
|
||||||
username = "yourname"
|
|
||||||
url = "https://linkedin.com/in/yourname"
|
|
||||||
|
|
||||||
[[basics.profiles]]
|
[[basics.profiles]]
|
||||||
network = "Bluesky"
|
network = "Bluesky"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { getCollection, type CollectionEntry } from "astro:content";
|
import { getCollection, render, type CollectionEntry } from "astro:content";
|
||||||
import { Icon } from "astro-icon/components";
|
import { Icon } from "astro-icon/components";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
|
||||||
@@ -8,13 +8,13 @@ export const prerender = true;
|
|||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = await getCollection("posts");
|
const posts = await getCollection("posts");
|
||||||
return posts.map((post: CollectionEntry<"posts">) => ({
|
return posts.map((post: CollectionEntry<"posts">) => ({
|
||||||
params: { slug: post.slug },
|
params: { slug: post.id },
|
||||||
props: { post },
|
props: { post },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { post }: { post: CollectionEntry<"posts"> } = Astro.props;
|
const { post }: { post: CollectionEntry<"posts"> } = Astro.props;
|
||||||
const { Content } = await post.render();
|
const { Content } = await render(post);
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|||||||
@@ -30,92 +30,122 @@ function formatDate(date: Date): string {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Mobile: One-sided compact timeline */}
|
{/* Mobile: One-sided compact timeline */}
|
||||||
<ul class="timeline timeline-vertical timeline-compact timeline-snap-icon max-w-3xl mx-auto px-4 md:hidden">
|
<ul
|
||||||
|
class="timeline timeline-vertical timeline-compact timeline-snap-icon max-w-3xl mx-auto px-4 md:hidden"
|
||||||
|
>
|
||||||
{
|
{
|
||||||
sortedPosts.map((post, index) => (
|
sortedPosts.map((post, index) => (
|
||||||
<li>
|
<li>
|
||||||
{index > 0 && <hr class="bg-primary" />}
|
{index > 0 && <hr class="bg-primary" />}
|
||||||
|
|
||||||
<div class="timeline-middle">
|
<div class="timeline-middle">
|
||||||
<Icon name="mdi:circle" class="w-4 h-4 text-primary" />
|
<Icon
|
||||||
|
name="mdi:circle"
|
||||||
|
class="w-4 h-4 text-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="timeline-end mb-8 ml-4">
|
<div class="timeline-end mb-8 ml-4">
|
||||||
<div class="border border-base-content/20 rounded-box p-4 bg-base-200 hover:border-primary transition-colors">
|
<div class="border border-base-content/20 rounded-box p-4 bg-base-200 hover:border-primary transition-colors">
|
||||||
<time class="font-mono text-sm opacity-60">
|
<time class="font-mono text-sm opacity-60">
|
||||||
{formatDate(new Date(post.data.pubDate))}
|
{formatDate(new Date(post.data.pubDate))}
|
||||||
</time>
|
</time>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`/post/${post.slug}`}
|
href={`/post/${post.id}`}
|
||||||
class="block group"
|
class="block group"
|
||||||
>
|
>
|
||||||
<h3 class="text-lg font-bold text-primary group-hover:text-accent transition-colors">
|
<h3 class="text-lg font-bold text-primary group-hover:text-accent transition-colors">
|
||||||
{post.data.title}
|
{post.data.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p class="text-sm opacity-80 mt-1">
|
<p class="text-sm opacity-80 mt-1">
|
||||||
{post.data.description || "No description available."}
|
{post.data.description ||
|
||||||
|
"No description available."}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{post.data.tags && post.data.tags.length > 0 && (
|
{post.data.tags &&
|
||||||
<div class="flex gap-1 flex-wrap mt-2">
|
post.data.tags.length > 0 && (
|
||||||
{post.data.tags.slice(0, 3).map((tag: string) => (
|
<div class="flex gap-1 flex-wrap mt-2">
|
||||||
<span class="badge badge-sm badge-outline">{tag}</span>
|
{post.data.tags
|
||||||
))}
|
.slice(0, 3)
|
||||||
</div>
|
.map((tag: string) => (
|
||||||
)}
|
<span class="badge badge-sm badge-outline">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{index < sortedPosts.length - 1 && <hr class="bg-primary" />}
|
{index < sortedPosts.length - 1 && (
|
||||||
|
<hr class="bg-primary" />
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Desktop: Dual-sided alternating timeline */}
|
{/* Desktop: Dual-sided alternating timeline */}
|
||||||
<ul class="timeline timeline-vertical timeline-snap-icon max-w-3xl mx-auto px-4 hidden md:block">
|
<ul
|
||||||
|
class="timeline timeline-vertical timeline-snap-icon max-w-3xl mx-auto px-4 hidden md:block"
|
||||||
|
>
|
||||||
{
|
{
|
||||||
sortedPosts.map((post, index) => (
|
sortedPosts.map((post, index) => (
|
||||||
<li>
|
<li>
|
||||||
{index > 0 && <hr class="bg-primary" />}
|
{index > 0 && <hr class="bg-primary" />}
|
||||||
|
|
||||||
<div class="timeline-middle">
|
<div class="timeline-middle">
|
||||||
<Icon name="mdi:circle" class="w-4 h-4 text-primary" />
|
<Icon
|
||||||
|
name="mdi:circle"
|
||||||
|
class="w-4 h-4 text-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={`timeline-${index % 2 === 0 ? 'start' : 'end'} text-${index % 2 === 0 ? 'end' : 'start'} mb-8 mx-4`}>
|
<div
|
||||||
|
class={`timeline-${index % 2 === 0 ? "start" : "end"} text-${index % 2 === 0 ? "end" : "start"} mb-8 mx-4`}
|
||||||
|
>
|
||||||
<div class="border border-base-content/20 rounded-box p-4 bg-base-200 hover:border-primary transition-colors">
|
<div class="border border-base-content/20 rounded-box p-4 bg-base-200 hover:border-primary transition-colors">
|
||||||
<time class="font-mono text-sm opacity-60">
|
<time class="font-mono text-sm opacity-60">
|
||||||
{formatDate(new Date(post.data.pubDate))}
|
{formatDate(new Date(post.data.pubDate))}
|
||||||
</time>
|
</time>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`/post/${post.slug}`}
|
href={`/post/${post.id}`}
|
||||||
class="block group"
|
class="block group"
|
||||||
>
|
>
|
||||||
<h3 class="text-lg font-bold text-primary group-hover:text-accent transition-colors">
|
<h3 class="text-lg font-bold text-primary group-hover:text-accent transition-colors">
|
||||||
{post.data.title}
|
{post.data.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p class="text-sm opacity-80 mt-1">
|
<p class="text-sm opacity-80 mt-1">
|
||||||
{post.data.description || "No description available."}
|
{post.data.description ||
|
||||||
|
"No description available."}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{post.data.tags && post.data.tags.length > 0 && (
|
{post.data.tags &&
|
||||||
<div class={`flex gap-1 flex-wrap mt-2 ${index % 2 === 0 ? 'justify-end' : 'justify-start'}`}>
|
post.data.tags.length > 0 && (
|
||||||
{post.data.tags.slice(0, 3).map((tag: string) => (
|
<div
|
||||||
<span class="badge badge-sm badge-outline">{tag}</span>
|
class={`flex gap-1 flex-wrap mt-2 ${index % 2 === 0 ? "justify-end" : "justify-start"}`}
|
||||||
))}
|
>
|
||||||
</div>
|
{post.data.tags
|
||||||
)}
|
.slice(0, 3)
|
||||||
|
.map((tag: string) => (
|
||||||
|
<span class="badge badge-sm badge-outline">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{index < sortedPosts.length - 1 && <hr class="bg-primary" />}
|
{index < sortedPosts.length - 1 && (
|
||||||
|
<hr class="bg-primary" />
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from "astro-icon/components";
|
import { Icon } from "astro-icon/components";
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import ResumeSkills from "../components/ResumeSkills";
|
import ResumeSkills from "../components/ResumeSkills.vue";
|
||||||
import ResumeDownloadButton from "../components/ResumeDownloadButton";
|
import ResumeDownloadButton from "../components/ResumeDownloadButton.vue";
|
||||||
import ResumeSettingsModal from "../components/ResumeSettingsModal";
|
import ResumeSettingsModal from "../components/ResumeSettingsModal.vue";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
import * as TOML from "@iarna/toml";
|
import * as TOML from "@iarna/toml";
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
import rss from '@astrojs/rss';
|
import { getCollection } from "astro:content";
|
||||||
import { getCollection } from 'astro:content';
|
|
||||||
|
|
||||||
function formatPubDate(date) {
|
function formatPubDate(date) {
|
||||||
const timezone = process.env.PUBLIC_RSS_TIMEZONE
|
const timezone = process.env.PUBLIC_RSS_TIMEZONE
|
||||||
? process.env.PUBLIC_RSS_TIMEZONE
|
? process.env.PUBLIC_RSS_TIMEZONE
|
||||||
: import.meta.env.PUBLIC_RSS_TIMEZONE;
|
: import.meta.env.PUBLIC_RSS_TIMEZONE;
|
||||||
|
|
||||||
if (!timezone) {
|
if (!timezone) {
|
||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const year = date.getUTCFullYear();
|
const year = date.getUTCFullYear();
|
||||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||||
|
|
||||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||||
timeZone: timezone,
|
timeZone: timezone,
|
||||||
timeZoneName: 'longOffset',
|
timeZoneName: "longOffset",
|
||||||
});
|
});
|
||||||
|
|
||||||
const parts = formatter.formatToParts(date);
|
const parts = formatter.formatToParts(date);
|
||||||
const offsetPart = parts.find(p => p.type === 'timeZoneName');
|
const offsetPart = parts.find((p) => p.type === "timeZoneName");
|
||||||
const offset = offsetPart ? offsetPart.value.replace('GMT', '') : '+00:00';
|
const offset = offsetPart ? offsetPart.value.replace("GMT", "") : "+00:00";
|
||||||
|
|
||||||
const dateStr = `${year}-${month}-${day}T00:00:00${offset}`;
|
const dateStr = `${year}-${month}-${day}T00:00:00${offset}`;
|
||||||
|
|
||||||
return new Date(dateStr);
|
return new Date(dateStr);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Invalid timezone "${timezone}":`, e.message);
|
console.warn(`Invalid timezone "${timezone}":`, e.message);
|
||||||
@@ -34,18 +33,45 @@ function formatPubDate(date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(context) {
|
export async function GET(context) {
|
||||||
const posts = await getCollection('posts');
|
const posts = await getCollection("posts");
|
||||||
|
|
||||||
return rss({
|
// Sort posts by date, newest first
|
||||||
title: 'Atridad Lahiji',
|
posts.sort((a, b) => new Date(b.data.pubDate) - new Date(a.data.pubDate));
|
||||||
description: 'Recent posts from Atridad Lahiji',
|
|
||||||
site: context.site,
|
const siteUrl = context.site?.toString().replace(/\/$/, "") || "";
|
||||||
items: posts.map((post) => ({
|
|
||||||
title: post.data.title,
|
const items = posts
|
||||||
pubDate: formatPubDate(post.data.pubDate),
|
.map((post) => {
|
||||||
description: post.data.description || '',
|
const title = post.data.title;
|
||||||
link: `/post/${post.slug}/`,
|
const description = post.data.description || "";
|
||||||
})),
|
const link = `${siteUrl}/post/${post.id}/`;
|
||||||
customData: `<language>en-us</language>`,
|
const pubDate = formatPubDate(post.data.pubDate).toUTCString();
|
||||||
|
|
||||||
|
return ` <item>
|
||||||
|
<title><![CDATA[${title}]]></title>
|
||||||
|
<link>${link}</link>
|
||||||
|
<guid isPermaLink="true">${link}</guid>
|
||||||
|
<description><![CDATA[${description}]]></description>
|
||||||
|
<pubDate>${pubDate}</pubDate>
|
||||||
|
</item>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title>Atridad Lahiji</title>
|
||||||
|
<description>Recent posts from Atridad Lahiji</description>
|
||||||
|
<link>${siteUrl}/</link>
|
||||||
|
<language>en-us</language>
|
||||||
|
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml" />
|
||||||
|
${items}
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
|
||||||
|
return new Response(rssXml, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/xml",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export const ResumeDocument = ({ data, resumeConfig, icons }: ResumeDocumentProp
|
|||||||
return sectionNames.map((name) => {
|
return sectionNames.map((name) => {
|
||||||
const config = resumeConfig.sections[name];
|
const config = resumeConfig.sections[name];
|
||||||
const content = renderSectionContent(name);
|
const content = renderSectionContent(name);
|
||||||
|
|
||||||
// Check if section has content (simple check)
|
// Check if section has content (simple check)
|
||||||
const hasContent = data[name] && data[name].length > 0;
|
const hasContent = data[name] && data[name].length > 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ImageMetadata } from "astro";
|
import type { ImageMetadata } from "astro";
|
||||||
import type { ComponentType } from "preact";
|
import type { Component } from "vue";
|
||||||
import type { GiteaRepoInfo } from "./utils/gitea";
|
import type { GiteaRepoInfo } from "./utils/gitea";
|
||||||
|
|
||||||
// Icon Types
|
// Icon Types
|
||||||
export type LucideIcon = ComponentType<{ size?: number; class?: string }>;
|
export type LucideIcon = Component;
|
||||||
export type AstroIconName = string; // For astro-icon string references like "mdi:email"
|
export type AstroIconName = string; // For astro-icon string references like "mdi:email"
|
||||||
export type CustomIconComponent = ComponentType<any>;
|
export type CustomIconComponent = Component;
|
||||||
export type IconType = LucideIcon | AstroIconName | CustomIconComponent;
|
export type IconType = LucideIcon | AstroIconName | CustomIconComponent;
|
||||||
|
|
||||||
export interface Talk {
|
export interface Talk {
|
||||||
|
|||||||
Reference in New Issue
Block a user