This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user