Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions solutions/animated-portfolio-blog/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Site URL used for metadata, sitemap, OG tags, JSON-LD.
# Set explicitly on Vercel Production. Vercel Preview auto-resolves via
# VERCEL_URL (read directly in app/sitemap.ts); dev falls through to the
# CommonConfig.siteUrlFallback default.
NEXT_PUBLIC_SITE_URL=""

# Google Analytics measurement ID (e.g. G-XXXXXXXXXX).
# Set to empty string to disable analytics.
NEXT_PUBLIC_GA_ID=""

# Medium username (without the @) whose RSS feed powers /blog.
# Leave empty to render an empty blog list.
MEDIUM_USERNAME=""
42 changes: 42 additions & 0 deletions solutions/animated-portfolio-blog/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# Dependencies
/node_modules
/.pnp
.pnp.js

# Testing
/coverage

# Next.js
/.next/
/out/
next-env.d.ts

# Production
build
dist

# Misc
.DS_Store
*.pem

# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Local ENV files
.env.local
.env.development.local
.env.test.local
.env.production.local

# Vercel
.vercel

# Turborepo
.turbo

# typescript
*.tsbuildinfo
21 changes: 21 additions & 0 deletions solutions/animated-portfolio-blog/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025 Ali Farooqi

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
85 changes: 85 additions & 0 deletions solutions/animated-portfolio-blog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
name: Animated Portfolio Blog
slug: animated-portfolio-blog
publisher: Ali Farooqi
description: Animated single-page portfolio + Medium-sourced blog. Config-driven, dynamic OG, e2e CI.
framework:
- Next.js
type:
- Portfolio
- Blog
- Starter
css:
- Tailwind
- CSS
githubUrl: https://github.com/alifarooqi/portfolio-blog-starter
demoUrl: https://portfolio-blog-starter-two.vercel.app
relatedTemplates:
- nextjs-boilerplate
- blog
- monorepo-turborepo
deployUrl: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fexamples%2Ftree%2Fmain%2Fsolutions%2Fanimated-portfolio-blog&project-name=animated-portfolio-blog&repository-name=animated-portfolio-blog&env=NEXT_PUBLIC_SITE_URL,MEDIUM_USERNAME,NEXT_PUBLIC_GA_ID&envDescription=Canonical%20site%20URL%2C%20Medium%20username%20(no%20%40)%2C%20and%20GA4%20ID
---

# Animated Portfolio Blog

A Next.js App Router portfolio + blog starter. Animated single-page home, dynamic Open Graph cards, Medium-sourced blog, Tailwind v4 + SCSS, MIT-licensed.

![Portfolio preview](https://portfolio-blog-starter-two.vercel.app/og)

- **Animated sections** powered by `motion` — staggered entrance, scroll-spy, magnetic buttons.
- **Config-driven home page** — name, tagline, social, signature SVG, and metadata all live in `app/config/CommonConfig.ts`. Edit one file, the whole site updates.
- **Blog backed by a Medium RSS feed** — `lib/medium.ts` fetches and parses the feed, falls back to a committed snapshot if the network fails, sanitizes content with an allowlist via `isomorphic-dompurify`.
- **Dynamic Open Graph cards** — `app/og/route.tsx` renders a branded 1200×630 image per page and per blog post.
- **SEO built in** — sitemap, robots, JSON-LD `Person` and `BlogPosting` schemas, per-page metadata via `generateMetadata`.
- **Dark mode** — class-based, no flash on first paint.
- **Vercel Analytics + Speed Insights** wired up.
- **Two test layers** — Vitest unit tests for pure logic, Playwright e2e smoke tests against a real production build.

## Demo

https://portfolio-blog-starter-two.vercel.app

A live example implementation by the author is also deployed at <https://alifarooqi.vercel.app>.

## How to Use

You can choose from one of the following two methods to use this repository:

### One-Click Deploy

Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=vercel-examples):

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fexamples%2Ftree%2Fmain%2Fsolutions%2Fanimated-portfolio-blog&project-name=animated-portfolio-blog&repository-name=animated-portfolio-blog&env=NEXT_PUBLIC_SITE_URL,MEDIUM_USERNAME,NEXT_PUBLIC_GA_ID&envDescription=Canonical%20site%20URL%2C%20Medium%20username%20(no%20%40)%2C%20and%20GA4%20ID)

### Clone and Deploy

Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [pnpm](https://pnpm.io/installation) to bootstrap the example:

```bash
pnpm create next-app --example https://github.com/vercel/examples/tree/main/solutions/animated-portfolio-blog animated-portfolio-blog
```

Then run Next.js in development mode:

```bash
pnpm dev
```

Deploy it to the cloud with [Vercel](https://vercel.com/templates) ([Documentation](https://nextjs.org/docs/app/building-your-application/deploying)).

## Configuration

Copy `.env.example` to `.env.local` and set the values:

| Var | Required? | Purpose |
| ---------------------- | ---------- | -------------------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_SITE_URL` | Production | Canonical URL used in sitemap, OG tags, JSON-LD. Vercel preview auto-resolves via `VERCEL_URL` if unset. |
| `NEXT_PUBLIC_GA_ID` | Optional | Google Analytics 4 measurement ID (e.g. `G-XXXXXXXXXX`). Empty disables analytics. |
| `MEDIUM_USERNAME` | Optional | Medium handle (no `@`) that powers `/blog`. Empty renders an empty list. |

Everything else lives in code under `app/config/`.

## Upstream repository

This example is vendored from the upstream starter for inclusion in the `vercel/examples` gallery. For the latest features, issues, and PRs, see [alifarooqi/portfolio-blog-starter](https://github.com/alifarooqi/portfolio-blog-starter).
32 changes: 32 additions & 0 deletions solutions/animated-portfolio-blog/app/GoogleAnalyticsScript.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Script from "next/script";

// Public so the client bundle can read it. Empty string disables analytics
// entirely (see the guard inside the component). Set NEXT_PUBLIC_GA_ID in
// `.env.local` to your GA4 measurement ID.
const GA_ID = process.env.NEXT_PUBLIC_GA_ID ?? "";

// Loaded with strategy="lazyOnload" so gtag.js doesn't contend with first
// paint — Lighthouse flagged ~62 KiB of unused gtag JS with an estimated
// ~450 ms LCP saving on mobile. Analytics is non-critical, so deferring it
// until the browser is idle is the right trade.
const GoogleAnalyticsScript = () => {
// Empty string = opt-out (preview branches). Avoids firing a broken
// request to gtag with no id.
if (!GA_ID) return null;

return (
<>
<Script src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`} strategy="lazyOnload" />
<Script id="ga" strategy="lazyOnload">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_ID}');
`}
</Script>
</>
);
};

export default GoogleAnalyticsScript;
33 changes: 33 additions & 0 deletions solutions/animated-portfolio-blog/app/SoundInitializerScript.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Script from "next/script";

// Mirrors ThemeInitializerScript: reads the persisted sound preference from
// localStorage and sets document.documentElement.dataset.sound BEFORE React
// hydrates, so useIsMuted returns the right value on first client render and
// playSound never fires for a returning-muted user before the toggle renders.
//
// Default is muted — a first-time visitor (no localStorage entry) gets no
// audio until they explicitly opt in via the radial Menu toggle.

const soundInitializerScript = `
(function() {
try {
var muted = localStorage.getItem('sound-muted');
document.documentElement.dataset.sound = (muted === 'false') ? 'unmuted' : 'muted';
} catch (e) {
// storage disabled (private mode / cookies off) — fall back to default muted
document.documentElement.dataset.sound = 'muted';
}
})();
`;

const SoundInitializerScript = () => {
return (
<Script
id="sound-initializer"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: soundInitializerScript }}
/>
);
};

export default SoundInitializerScript;
34 changes: 34 additions & 0 deletions solutions/animated-portfolio-blog/app/ThemeInitializerScript.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Script from "next/script";

// The script must be defined as a string for use with next/script
const themeInitializerScript = `
(function() {
// This function must run immediately
try {
const theme = localStorage.getItem('theme');
if (theme) {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
localStorage.setItem('theme', 'dark');
document.documentElement.classList.add('dark');
}
} catch (e) {
// Catch potential security errors if cookies/storage are disabled
console.error('Failed to initialize theme:', e);
}
})();
`;

const ThemeInitializerScript = () => {
return (
<Script
id="theme-initializer" // Unique ID for the script
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: themeInitializerScript }}
/>
);
};

export default ThemeInitializerScript;
52 changes: 52 additions & 0 deletions solutions/animated-portfolio-blog/app/blog/BlogCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import Link from "next/link";
import Image from "next/image";
import type { MediumPost } from "@/lib/medium";
import { playSound } from "@/lib/sound";

// Extracted from the /blog listing (app/blog/page.tsx) so the card can attach
// hover/click sound handlers as a client component while the page itself stays
// server-side (it owns the Medium fetch + revalidate + metadata).
export default function BlogCard({ post }: { post: MediumPost }) {
return (
<Link
href={`/blog/${post.slug}`}
className="
group
block
rounded-xl
overflow-hidden
shadow-sm
transition-all
duration-300
hover:shadow-xl
hover:-translate-y-1
hover:scale-[1.02]
!no-underline
blog-card
"
onMouseEnter={() => playSound("hover")}
onClick={() => playSound("select")}
>
{post.image && (
<div className="relative w-full h-48 overflow-hidden">
<Image
src={post.image}
alt={post.title ?? ""}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover opacity-80 transition-all duration-300 group-hover:opacity-100"
/>
</div>
)}
<div className="p-5">
<h2 className="text-xl font-semibold">{post.title}</h2>
{post.date && (
<p className="text-sm mt-1 italic">{new Date(post.date).toDateString()}</p>
)}
<p className="mt-3 line-clamp-3">{post.summary}</p>
</div>
</Link>
);
}
Loading