Skip to content

Commit 00a9d9e

Browse files
committed
feat: enhance portfolio with PWA, image optimization, and advanced UX features
1 parent e7d1ed7 commit 00a9d9e

24 files changed

Lines changed: 703 additions & 281 deletions

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ PUBLIC_SITE_URL=https://medalcode.github.io
44
# Analytics (Optional)
55
# PUBLIC_VERCEL_ANALYTICS_ID=
66

7-
# Contact Form (Optional)
7+
# Contact Form
8+
PUBLIC_WEB3FORMS_ACCESS_KEY=YOUR_ACCESS_KEY_HERE
89
# PUBLIC_FORM_ENDPOINT=
910

1011
# Social Media (Optional)

README.md

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,29 +74,39 @@
7474

7575
## ✨ Key Features
7676

77-
1. **🚀 Performance Optimized**
78-
- Static site generation
79-
- Partial hydration with Preact
80-
- Optimized images and assets
81-
82-
2. **💻 Modern Development Experience**
77+
1. **🚀 Pro Performance**
78+
- **New**: `astro:assets` image optimization with AVIF/WebP support.
79+
- **New**: Native View Transitions for smooth, app-like navigation.
80+
- Static site generation (SSG) for blazing fast load times.
81+
- Partial hydration with Preact.
82+
83+
2. **📱 PWA & Offline Ready**
84+
- **New**: Full Progressive Web App (PWA) support.
85+
- **New**: Service Worker for offline capabilities and caching.
86+
- **New**: Installable on mobile and desktop devices.
87+
88+
3. **💻 Modern Development Experience**
8389
- TypeScript support
8490
- Hot module replacement
85-
- ESLint integration
91+
- ESLint & Prettier integration
8692

87-
3. **🔍 SEO & Analytics**
93+
4. **🔍 SEO & Analytics**
94+
- **New**: Enhanced JSON-LD Rich Snippets (Person Schema).
95+
- **New**: Dynamic RSS Feed generation using Content Collections.
8896
- Built-in sitemap generation
89-
- RSS feed support
9097
- Vercel Speed Insights
9198

92-
4. **🎨 Styling & UI**
93-
- TailwindCSS for utility-first styling
94-
- **New**: Animated components with `tailwindcss-animated` (Scroll Reveal)
95-
- **New**: Enhanced Dark Mode with anti-FOUC protection
96-
- **New**: Custom 404 Error Page with auto-language detection
97-
- Responsive design
98-
99-
5. **🌐 Internationalization**
99+
5. **🎨 Styling & UI**
100+
- TailwindCSS v4 with utility-first styling.
101+
- **New**: Reading Time estimation for blog posts.
102+
- **New**: AJAX Contact Form with immediate feedback and security.
103+
- **New**: Copy Code functionality with visual feedback.
104+
- Animated components with `tailwindcss-animated` (Scroll Reveal).
105+
- Enhanced Dark Mode with anti-FOUC protection.
106+
- Custom 404 Error Page with auto-language detection.
107+
- Responsive design.
108+
109+
6. **🌐 Internationalization**
100110
- Multi-language support (English/Spanish)
101111
- **New**: Intuitive Language Picker (ES | EN toggle)
102112
- **New**: Smart Navigation with mobile support

astro.config.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ import tailwindcss from "@tailwindcss/vite";
44
import preact from "@astrojs/preact";
55
import sitemap from "@astrojs/sitemap"
66
import icon from "astro-icon";
7+
import { toString } from 'mdast-util-to-string';
8+
import getReadingTime from 'reading-time';
9+
10+
export function remarkReadingTime() {
11+
return function (tree, { data }) {
12+
const textOnPage = toString(tree);
13+
const readingTime = getReadingTime(textOnPage);
14+
// readingTime.minutes will give us the number of minutes
15+
data.astro.frontmatter.readingTime = Math.ceil(readingTime.minutes);
16+
};
17+
}
718

819
// https://astro.build/config
920
export default defineConfig({
@@ -14,6 +25,7 @@ export default defineConfig({
1425
plugins: [tailwindcss()],
1526
},
1627
markdown: {
28+
remarkPlugins: [remarkReadingTime],
1729
shikiConfig: {
1830
theme: 'github-dark'
1931
},

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@
5252
"alpinejs": "^3.14.9",
5353
"astro": "^5.6.1",
5454
"astro-icon": "^1.1.5",
55+
"mdast-util-to-string": "^4.0.0",
5556
"preact": "^10.26.2",
5657
"prismjs": "^1.30.0",
58+
"reading-time": "^1.5.0",
5759
"tailwindcss": "^4.1.8"
5860
},
5961
"devDependencies": {

public/site.webmanifest

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,21 @@
1-
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
1+
{
2+
"name": "Fernando López | EFEELE Portfolio",
3+
"short_name": "EFEELE",
4+
"icons": [
5+
{
6+
"src": "/android-chrome-192x192.png",
7+
"sizes": "192x192",
8+
"type": "image/png"
9+
},
10+
{
11+
"src": "/android-chrome-512x512.png",
12+
"sizes": "512x512",
13+
"type": "image/png"
14+
}
15+
],
16+
"theme_color": "#0E0E11",
17+
"background_color": "#0E0E11",
18+
"display": "standalone",
19+
"start_url": "/",
20+
"description": "Portfolio and tech blog of Fernando López (EFEELE), Web Developer and Software Architect."
21+
}

public/sw.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
const CACHE_NAME = 'medalcode-v1';
2+
const URLS_TO_CACHE = [
3+
'/',
4+
'/site.webmanifest',
5+
'/favicon-32x32.png',
6+
'/favicon-16x16.png',
7+
'/styles/global.css' // This might be built into something else, so maybe skip specific assets unless we know the build path
8+
];
9+
10+
// Install Event
11+
self.addEventListener('install', (event) => {
12+
event.waitUntil(
13+
caches.open(CACHE_NAME).then((cache) => {
14+
// Omit errors if files don't exist (like specific CSS bundles)
15+
return cache.addAll(URLS_TO_CACHE).catch(err => console.log('SW: Cache addAll error', err));
16+
})
17+
);
18+
self.skipWaiting();
19+
});
20+
21+
// Activate Event
22+
self.addEventListener('activate', (event) => {
23+
event.waitUntil(
24+
caches.keys().then((cacheNames) => {
25+
return Promise.all(
26+
cacheNames.map((cacheName) => {
27+
if (cacheName !== CACHE_NAME) {
28+
return caches.delete(cacheName);
29+
}
30+
})
31+
);
32+
})
33+
);
34+
self.clients.claim();
35+
});
36+
37+
// Fetch Event
38+
self.addEventListener('fetch', (event) => {
39+
// Navigation requests (HTML pages)
40+
if (event.request.mode === 'navigate') {
41+
event.respondWith(
42+
fetch(event.request)
43+
.then((response) => {
44+
// If network works, return response and cache it
45+
const resClone = response.clone();
46+
caches.open(CACHE_NAME).then((cache) => {
47+
cache.put(event.request, resClone);
48+
});
49+
return response;
50+
})
51+
.catch(() => {
52+
// If network fails, try cache
53+
return caches.match(event.request).then((response) => {
54+
if (response) return response;
55+
// Fallback to home page if specific page not in cache
56+
return caches.match('/');
57+
});
58+
})
59+
);
60+
return;
61+
}
62+
63+
// Asset requests (Images, CSS, JS)
64+
event.respondWith(
65+
caches.match(event.request).then((cachedResponse) => {
66+
// Return cached response if found
67+
if (cachedResponse) {
68+
// Optional: Update cache in background (Stale-while-revalidate)
69+
fetch(event.request).then(response => {
70+
if(response && response.status === 200) {
71+
const resClone = response.clone();
72+
caches.open(CACHE_NAME).then(cache => cache.put(event.request, resClone));
73+
}
74+
}).catch(() => {});
75+
76+
return cachedResponse;
77+
}
78+
79+
// If not in cache, fetch from network
80+
return fetch(event.request).then((response) => {
81+
if (!response || response.status !== 200 || response.type !== 'basic') {
82+
return response;
83+
}
84+
85+
const responseToCache = response.clone();
86+
caches.open(CACHE_NAME).then((cache) => {
87+
cache.put(event.request, responseToCache);
88+
});
89+
90+
return response;
91+
});
92+
})
93+
);
94+
});

src/components/blog/BlogPost.astro

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,30 @@
11
---
2-
const { title, url, date, image, tags = [], languages = [] } = Astro.props;
2+
const { title, url, date, image, tags = [], languages = [], readingTime } = Astro.props;
3+
const { lang = "es" } = Astro.params;
4+
const readingTimeLabel = lang === "en" ? "min read" : "min de lectura";
35
46
import Tag from "../ui/Tag.astro";
57
import ReadMore from "../ui/ReadMore.astro";
68
import DatePub from "./DatePub.astro";
79
import Capsule from "../ui/Capsule.astro";
10+
import { Icon } from "astro-icon/components";
11+
import { Image } from "astro:assets";
812
---
913

1014
<article
1115
class="bg-white dark:bg-zinc-900/25 dark:border dark:border-zinc-800 dark:hover:border-mint-300 hover:backdrop-blur-none backdrop-blur-lg shadow-sm overflow-auto hover:shadow-[5px_5px_rgba(0,98,90,0.4),10px_10px_rgba(0,98,90,0.3),15px_15px_rgba(0,98,90,0.2),20px_20px_rgba(0,98,90,0.1),25px_25px_rgba(0,98,90,0.05)] p-8 max-md:p-6 w-full flex justify-between items-center bg-linear-to-r hover:from-teal-200 hover:to-emerald-200 dark:hover:from-riptide-500 dark:hover:to-mint-500 transition-all hover:scale-105 duration-200 ease-in-out gap-8 max-md:gap-4 rounded-3xl max-md:flex-col-reverse"
1216
>
1317
<div class="flex flex-col">
1418
<a href={url} class="flex flex-col gap-4 w-full">
15-
<DatePub date={date} />
19+
<div class="flex items-center gap-4">
20+
<DatePub date={date} />
21+
{readingTime && (
22+
<span class="flex items-center gap-1 text-zinc-500 dark:text-zinc-400 text-sm font-medium">
23+
<Icon name="clock" class="size-4" />
24+
{readingTime} {readingTimeLabel}
25+
</span>
26+
)}
27+
</div>
1628
<h2 class="dark:text-mint-50 text-blacktext text-3xl font-bold text-pretty">{title}</h2>
1729
<ReadMore />
1830
</a>
@@ -29,10 +41,17 @@ import Capsule from "../ui/Capsule.astro";
2941
image?.url && (
3042
<a
3143
href={url}
32-
style={{ backgroundImage: `url(${image.url})` }}
33-
class="shrink-0 rounded-2xl bg-center bg-cover aspect-video max-md:aspect-video w-2/6 max-md:w-full"
44+
class="shrink-0 rounded-2xl overflow-hidden aspect-video max-md:aspect-video w-2/6 max-md:w-full group/img"
3445
aria-label={image.alt || title}
35-
/>
46+
>
47+
<Image
48+
src={image.url}
49+
alt={image.alt || title}
50+
width={600}
51+
height={338}
52+
class="w-full h-full object-cover transition-transform duration-500 group-hover/img:scale-110"
53+
/>
54+
</a>
3655
)
3756
}
3857
</article>

src/components/blog/LastPost.astro

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import Tag from "../ui/Tag.astro";
44
import ReadMore from "../ui/ReadMore.astro";
55
import Capsule from "../ui/Capsule.astro";
66
import DatePub from "./DatePub.astro";
7+
import { Image } from "astro:assets";
8+
import getReadingTime from "reading-time";
9+
import { Icon } from "astro-icon/components";
710
811
const currentPath = Astro.url.pathname;
912
const urlParts = currentPath.split("/").filter(Boolean);
@@ -36,24 +39,35 @@ const getPostUrl = (post: CollectionEntry<"blog">) => {
3639
const cleanSlug = slugParts.join("/");
3740
return `/${currentLang}/blog/posts/${cleanSlug}`;
3841
};
42+
43+
const readingTimeValue = latestPost ? Math.ceil(getReadingTime(latestPost.body).minutes) : 0;
44+
const readingTimeLabel = currentLang === "en" ? "min read" : "min de lectura";
3945
---
4046

4147
{
4248
latestPost && (
4349
<div
44-
style={{ backgroundImage: `url(${image})` }}
45-
class="h-full hover:shadow-[0_20px_50px_rgba(13,188,130,0.2)] flex flex-col overflow-hidden rounded-2xl bg-linear-to-br bg-center bg-cover transition-all ease-in-out duration-200"
50+
class="h-full hover:shadow-[0_20px_50px_rgba(13,188,130,0.2)] relative flex flex-col overflow-hidden rounded-2xl transition-all ease-in-out duration-200 group/last"
4651
role="article"
4752
aria-labelledby="post-title"
4853
>
49-
<article class="h-full flex flex-col justify-between max-sm:bg-zinc-900 max-sm:relative sm:bg-linear-to-t from-black/95 from-25% to-transparent max-sm:from-60% p-8 max-md:p-6 max-sm:mp-0 max-sm:p-0">
54+
<div class="absolute inset-0 z-0">
55+
<Image
56+
src={image}
57+
alt={imageAlt}
58+
width={1200}
59+
height={675}
60+
class="w-full h-full object-cover transition-transform duration-500 group-hover/last:scale-105"
61+
/>
62+
</div>
63+
<article class="h-full relative flex flex-col justify-between max-sm:bg-zinc-900/80 sm:bg-linear-to-t from-black/95 from-25% to-transparent max-sm:from-60% p-8 max-md:p-6 max-sm:mp-0 max-sm:p-0 z-10 backdrop-blur-[2px] sm:backdrop-blur-none">
5064
<a
5165
href={getPostUrl(latestPost)}
5266
class="sm:hidden relative top-0 left-0 w-full h-auto -z-0"
5367
aria-hidden="true"
5468
tabindex="-1"
5569
>
56-
<img src={image} alt={imageAlt} class="w-full h-auto" loading="lazy" />
70+
<Image src={image} alt={imageAlt} width={800} height={450} class="w-full h-auto" loading="lazy" />
5771
</a>
5872
<div
5973
class="w-full flex pb-5 gap-2 flex-wrap justify-end z-10 max-sm:px-6 max-sm:pt-6"
@@ -70,8 +84,14 @@ const getPostUrl = (post: CollectionEntry<"blog">) => {
7084
class="text-mint-50 gap-3 h-full flex items-end max-sm:px-6 rounded-lg transition-all"
7185
aria-label={`Read article: ${latestPost.data.title}`}
7286
>
73-
<div class="gap-3 flex flex-col justify-end drop-shadow-[1px_6px_1px_rgba(0,0,0,0.3)]">
74-
<DatePub date={latestPost.data.pubDate} class="text-mint-50" />
87+
<div class="gap-3 flex flex-col justify-end drop-shadow-[1px_6px_1px_rgba(0,0,0,0.5)]">
88+
<div class="flex items-center gap-4 text-mint-100 font-medium">
89+
<DatePub date={latestPost.data.pubDate} />
90+
<span class="flex items-center gap-1 text-sm">
91+
<Icon name="clock" class="size-4" />
92+
{readingTimeValue} {readingTimeLabel}
93+
</span>
94+
</div>
7595
<h2 id="post-title" class="text-4xl max-xl:text-3xl max-sm:text-2xl font-bold">
7696
<span>{latestPost.data.title}</span>
7797
</h2>

src/components/blog/ListPosts.astro

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
---
22
import BlogPost from "./BlogPost.astro";
33
import Heading from "../ui/Heading.astro";
4+
import getReadingTime from "reading-time";
5+
import { getCollection } from "astro:content";
46
57
// Prop to determine whether to exclude the latest post or a specific post
68
export interface Props {
@@ -12,7 +14,6 @@ export interface Props {
1214
const { excludeLatest = false, currentPostUrl = "", all = false } = Astro.props;
1315
1416
const { lang } = Astro.params;
15-
import { getCollection } from "astro:content";
1617
const allPosts = await getCollection("blog", ({ id }) => {
1718
return id.startsWith(`${lang}/`);
1819
});
@@ -65,6 +66,7 @@ if (!all) {
6566
tags={post.data.tags}
6667
languages={post.data.languages}
6768
image={post.data.image}
69+
readingTime={Math.ceil(getReadingTime(post.body).minutes)}
6870
/>
6971
))
7072
}

0 commit comments

Comments
 (0)