This commit is contained in:
@@ -3,34 +3,28 @@ import { config } from "../../../config";
|
||||
import * as TOML from "@iarna/toml";
|
||||
import { renderToStream } from "@react-pdf/renderer";
|
||||
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 {
|
||||
const response = await fetch(
|
||||
`https://cdn.jsdelivr.net/npm/simple-icons@v10/icons/${iconName.toLowerCase()}.svg`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch icon: ${iconName}`);
|
||||
return "";
|
||||
}
|
||||
const svgContent = await response.text();
|
||||
const match = svgContent.match(/d="([^"]+)"/);
|
||||
return match ? match[1] : "";
|
||||
const slug = network.toLowerCase().normalize("NFKD").replace(/[^\w]/g, "");
|
||||
const iconKey = `si${slug.charAt(0).toUpperCase()}${slug.slice(1)}`;
|
||||
|
||||
const icon = (simpleIcons as any)[iconKey];
|
||||
return icon ? icon.path : "";
|
||||
} catch (error) {
|
||||
console.warn(`Error fetching icon ${iconName}:`, error);
|
||||
console.warn(`Error finding icon for network: ${network}`, error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getMdiIconPath(iconName: string): string {
|
||||
const iconMap: { [key: string]: string } = {
|
||||
"mdi:email":
|
||||
"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":
|
||||
"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: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",
|
||||
"mdi:email": mdiEmail,
|
||||
"mdi:phone": mdiPhone,
|
||||
"mdi:download": mdiDownload,
|
||||
"mdi:link": mdiLink,
|
||||
};
|
||||
return iconMap[iconName] || "";
|
||||
}
|
||||
@@ -86,12 +80,11 @@ interface ResumeData {
|
||||
}[];
|
||||
}
|
||||
|
||||
const fetchProfileIcons = async (profiles: any[]) => {
|
||||
const fetchProfileIcons = (profiles: any[]) => {
|
||||
const profileIcons: { [key: string]: string } = {};
|
||||
if (profiles) {
|
||||
for (const profile of profiles) {
|
||||
const iconName = profile.network.toLowerCase();
|
||||
profileIcons[profile.network] = await getSimpleIconPath(iconName);
|
||||
profileIcons[profile.network] = getSimpleIconPath(profile.network);
|
||||
}
|
||||
}
|
||||
return profileIcons;
|
||||
@@ -99,17 +92,15 @@ const fetchProfileIcons = async (profiles: any[]) => {
|
||||
|
||||
const generatePDF = async (data: ResumeData) => {
|
||||
const resumeConfig = config.resumeConfig;
|
||||
|
||||
const profileIcons = await fetchProfileIcons(data.basics.profiles);
|
||||
const profileIcons = fetchProfileIcons(data.basics.profiles);
|
||||
|
||||
const icons = {
|
||||
...profileIcons,
|
||||
email: getMdiIconPath("mdi:email"),
|
||||
phone: getMdiIconPath("mdi:phone"),
|
||||
};
|
||||
|
||||
return await renderToStream(
|
||||
ResumeDocument({ data, resumeConfig, icons })
|
||||
);
|
||||
return await renderToStream(ResumeDocument({ data, resumeConfig, icons }));
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
@@ -137,9 +128,9 @@ export const GET: APIRoute = async ({ request }) => {
|
||||
}
|
||||
|
||||
const resumeData: ResumeData = TOML.parse(
|
||||
tomlContent,
|
||||
tomlContent,
|
||||
) as unknown as ResumeData;
|
||||
|
||||
|
||||
const stream = await generatePDF(resumeData);
|
||||
|
||||
return new Response(stream as any, {
|
||||
|
||||
@@ -16,10 +16,6 @@ network = "GitHub"
|
||||
username = "yourusername"
|
||||
url = "https://github.com/yourusername"
|
||||
|
||||
[[basics.profiles]]
|
||||
network = "LinkedIn"
|
||||
username = "yourname"
|
||||
url = "https://linkedin.com/in/yourname"
|
||||
|
||||
[[basics.profiles]]
|
||||
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 Layout from "../../layouts/Layout.astro";
|
||||
|
||||
@@ -8,13 +8,13 @@ export const prerender = true;
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection("posts");
|
||||
return posts.map((post: CollectionEntry<"posts">) => ({
|
||||
params: { slug: post.slug },
|
||||
params: { slug: post.id },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
|
||||
const { post }: { post: CollectionEntry<"posts"> } = Astro.props;
|
||||
const { Content } = await post.render();
|
||||
const { Content } = await render(post);
|
||||
---
|
||||
|
||||
<Layout>
|
||||
|
||||
@@ -30,92 +30,122 @@ function formatDate(date: Date): string {
|
||||
</h1>
|
||||
|
||||
{/* 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) => (
|
||||
<li>
|
||||
{index > 0 && <hr class="bg-primary" />}
|
||||
|
||||
|
||||
<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 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">
|
||||
<time class="font-mono text-sm opacity-60">
|
||||
{formatDate(new Date(post.data.pubDate))}
|
||||
</time>
|
||||
|
||||
<a
|
||||
href={`/post/${post.slug}`}
|
||||
|
||||
<a
|
||||
href={`/post/${post.id}`}
|
||||
class="block group"
|
||||
>
|
||||
<h3 class="text-lg font-bold text-primary group-hover:text-accent transition-colors">
|
||||
{post.data.title}
|
||||
</h3>
|
||||
|
||||
|
||||
<p class="text-sm opacity-80 mt-1">
|
||||
{post.data.description || "No description available."}
|
||||
{post.data.description ||
|
||||
"No description available."}
|
||||
</p>
|
||||
</a>
|
||||
|
||||
{post.data.tags && post.data.tags.length > 0 && (
|
||||
<div class="flex gap-1 flex-wrap mt-2">
|
||||
{post.data.tags.slice(0, 3).map((tag: string) => (
|
||||
<span class="badge badge-sm badge-outline">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post.data.tags &&
|
||||
post.data.tags.length > 0 && (
|
||||
<div class="flex gap-1 flex-wrap mt-2">
|
||||
{post.data.tags
|
||||
.slice(0, 3)
|
||||
.map((tag: string) => (
|
||||
<span class="badge badge-sm badge-outline">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{index < sortedPosts.length - 1 && <hr class="bg-primary" />}
|
||||
|
||||
{index < sortedPosts.length - 1 && (
|
||||
<hr class="bg-primary" />
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
{/* 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) => (
|
||||
<li>
|
||||
{index > 0 && <hr class="bg-primary" />}
|
||||
|
||||
|
||||
<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 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">
|
||||
<time class="font-mono text-sm opacity-60">
|
||||
{formatDate(new Date(post.data.pubDate))}
|
||||
</time>
|
||||
|
||||
<a
|
||||
href={`/post/${post.slug}`}
|
||||
|
||||
<a
|
||||
href={`/post/${post.id}`}
|
||||
class="block group"
|
||||
>
|
||||
<h3 class="text-lg font-bold text-primary group-hover:text-accent transition-colors">
|
||||
{post.data.title}
|
||||
</h3>
|
||||
|
||||
|
||||
<p class="text-sm opacity-80 mt-1">
|
||||
{post.data.description || "No description available."}
|
||||
{post.data.description ||
|
||||
"No description available."}
|
||||
</p>
|
||||
</a>
|
||||
|
||||
{post.data.tags && post.data.tags.length > 0 && (
|
||||
<div class={`flex gap-1 flex-wrap mt-2 ${index % 2 === 0 ? 'justify-end' : 'justify-start'}`}>
|
||||
{post.data.tags.slice(0, 3).map((tag: string) => (
|
||||
<span class="badge badge-sm badge-outline">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post.data.tags &&
|
||||
post.data.tags.length > 0 && (
|
||||
<div
|
||||
class={`flex gap-1 flex-wrap mt-2 ${index % 2 === 0 ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{post.data.tags
|
||||
.slice(0, 3)
|
||||
.map((tag: string) => (
|
||||
<span class="badge badge-sm badge-outline">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{index < sortedPosts.length - 1 && <hr class="bg-primary" />}
|
||||
|
||||
{index < sortedPosts.length - 1 && (
|
||||
<hr class="bg-primary" />
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import ResumeSkills from "../components/ResumeSkills";
|
||||
import ResumeDownloadButton from "../components/ResumeDownloadButton";
|
||||
import ResumeSettingsModal from "../components/ResumeSettingsModal";
|
||||
import ResumeSkills from "../components/ResumeSkills.vue";
|
||||
import ResumeDownloadButton from "../components/ResumeDownloadButton.vue";
|
||||
import ResumeSettingsModal from "../components/ResumeSettingsModal.vue";
|
||||
import { config } from "../config";
|
||||
import "../styles/global.css";
|
||||
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) {
|
||||
const timezone = process.env.PUBLIC_RSS_TIMEZONE
|
||||
? process.env.PUBLIC_RSS_TIMEZONE
|
||||
: import.meta.env.PUBLIC_RSS_TIMEZONE;
|
||||
|
||||
|
||||
if (!timezone) {
|
||||
return date;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'longOffset',
|
||||
timeZoneName: "longOffset",
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(date);
|
||||
const offsetPart = parts.find(p => p.type === 'timeZoneName');
|
||||
const offset = offsetPart ? offsetPart.value.replace('GMT', '') : '+00:00';
|
||||
const offsetPart = parts.find((p) => p.type === "timeZoneName");
|
||||
const offset = offsetPart ? offsetPart.value.replace("GMT", "") : "+00:00";
|
||||
|
||||
const dateStr = `${year}-${month}-${day}T00:00:00${offset}`;
|
||||
|
||||
|
||||
return new Date(dateStr);
|
||||
} catch (e) {
|
||||
console.warn(`Invalid timezone "${timezone}":`, e.message);
|
||||
@@ -34,18 +33,45 @@ function formatPubDate(date) {
|
||||
}
|
||||
|
||||
export async function GET(context) {
|
||||
const posts = await getCollection('posts');
|
||||
|
||||
return rss({
|
||||
title: 'Atridad Lahiji',
|
||||
description: 'Recent posts from Atridad Lahiji',
|
||||
site: context.site,
|
||||
items: posts.map((post) => ({
|
||||
title: post.data.title,
|
||||
pubDate: formatPubDate(post.data.pubDate),
|
||||
description: post.data.description || '',
|
||||
link: `/post/${post.slug}/`,
|
||||
})),
|
||||
customData: `<language>en-us</language>`,
|
||||
const posts = await getCollection("posts");
|
||||
|
||||
// Sort posts by date, newest first
|
||||
posts.sort((a, b) => new Date(b.data.pubDate) - new Date(a.data.pubDate));
|
||||
|
||||
const siteUrl = context.site?.toString().replace(/\/$/, "") || "";
|
||||
|
||||
const items = posts
|
||||
.map((post) => {
|
||||
const title = post.data.title;
|
||||
const description = post.data.description || "";
|
||||
const link = `${siteUrl}/post/${post.id}/`;
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user