Skip to content

Commit e2dc53a

Browse files
authored
feat(www): init blog (#20)
1 parent 34628d4 commit e2dc53a

12 files changed

Lines changed: 1992 additions & 58 deletions

File tree

apps/www/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# S3 Configuration for fetch-content script
2+
AWS_ACCESS_KEY_ID=your_access_key
3+
AWS_SECRET_ACCESS_KEY=your_secret_key
4+
AWS_REGION=us-east-1
5+
6+
# Optional: Override default S3 settings
7+
# S3_BUCKET_NAME=amical-www
8+
# S3_ENDPOINT=https://s3.wasabisys.com
9+
# BLOG_PREFIX=blog/
10+
# BLOG_IMAGES_PREFIX=blog-images/

apps/www/.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,9 @@ yarn-error.log*
2828
next-env.d.ts
2929

3030
# sitemap
31-
/public/sitemap.xml
31+
/public/sitemap.xml
32+
33+
# remote blog content
34+
/content/blogs/*
35+
/public/blog/*
36+
/content/tmp/*

apps/www/README.md

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,31 +15,29 @@ yarn dev
1515

1616
Open http://localhost:3000 with your browser to see the result.
1717

18-
## Explore
18+
## Content Management
1919

20-
In the project, you can see:
20+
### Fetching Blog Content
2121

22-
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
23-
- `app/layout.config.tsx`: Shared options for layouts, optional but preferred to keep.
22+
This project includes a script to fetch blog content and images from an S3-compatible storage (Wasabi):
2423

25-
| Route | Description |
26-
| ------------------------- | ------------------------------------------------------ |
27-
| `app/(home)` | The route group for your landing page and other pages. |
28-
| `app/docs` | The documentation layout and pages. |
29-
| `app/api/search/route.ts` | The Route Handler for search. |
24+
```bash
25+
# Set up environment variables (see .env.example)
26+
pnpm fetch-content
27+
```
3028

31-
### Fumadocs MDX
29+
The script will:
30+
- Fetch MDX files from the `blog/` folder in the S3 bucket and save them to `content/blogs/`
31+
- Fetch images from the `blog-images/` folder in the S3 bucket and save them to `public/blog/`
3232

33-
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
33+
### Building the Application
3434

35-
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
35+
The build process includes fetching content from S3:
3636

37-
## Learn More
37+
```bash
38+
pnpm build
39+
```
3840

39-
To learn more about Next.js and Fumadocs, take a look at the following
40-
resources:
41+
The build will fail if the content fetch fails. This ensures that the site is always built with the latest content and that any issues with the content fetch process are immediately apparent.
4142

42-
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
43-
features and API.
44-
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
45-
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs
43+
To use this in CI/CD environments, make sure to configure the appropriate AWS credentials as environment variables.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use client';
2+
import { Share } from 'lucide-react';
3+
import {
4+
TooltipContent,
5+
Tooltip,
6+
TooltipTrigger,
7+
} from '@radix-ui/react-tooltip';
8+
import { useState } from 'react';
9+
import { cn } from '@/lib/cn';
10+
import { buttonVariants } from '@/components/ui/button';
11+
12+
export function Control({ url }: { url: string }): React.ReactElement {
13+
const [open, setOpen] = useState(false);
14+
const onClick = (): void => {
15+
setOpen(true);
16+
void navigator.clipboard.writeText(`${window.location.origin}${url}`);
17+
};
18+
19+
return (
20+
<Tooltip open={open} onOpenChange={setOpen}>
21+
<TooltipTrigger
22+
className={cn(
23+
buttonVariants({ className: 'gap-2', variant: 'secondary' }),
24+
)}
25+
onClick={onClick}
26+
>
27+
<Share className="size-4" />
28+
Share Post
29+
</TooltipTrigger>
30+
<TooltipContent className="rounded-lg border bg-fd-popover p-2 text-sm text-fd-popover-foreground">
31+
Copied
32+
</TooltipContent>
33+
</Tooltip>
34+
);
35+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { Metadata } from 'next';
2+
import { notFound } from 'next/navigation';
3+
import Link from 'next/link';
4+
import Image from 'next/image';
5+
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
6+
import defaultMdxComponents from 'fumadocs-ui/mdx';
7+
import { blog } from '@/lib/source';
8+
import { File, Files, Folder } from 'fumadocs-ui/components/files';
9+
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
10+
import { ArrowLeft } from 'lucide-react';
11+
import { Button } from '@/components/ui/button';
12+
13+
export async function generateMetadata(props: {
14+
params: Promise<{ slug: string }>;
15+
}): Promise<Metadata> {
16+
const params = await props.params;
17+
const page = blog.getPage([params.slug]);
18+
19+
if (!page) notFound();
20+
21+
return {
22+
title: {
23+
absolute: `${page.data.title} | Amical`
24+
},
25+
description: page.data.description ?? 'A blog about Amical, Productivity and AI',
26+
openGraph: {
27+
title: `${page.data.title}`,
28+
description: page.data.description ?? 'A blog about Amical, Productivity and AI',
29+
type: 'article',
30+
images: page.data.image ? [{ url: page.data.image }] : undefined,
31+
},
32+
twitter: {
33+
card: 'summary_large_image',
34+
title: `${page.data.title}`,
35+
description: page.data.description ?? 'A blog about Amical, Productivity and AI',
36+
images: page.data.image ? [page.data.image] : undefined,
37+
},
38+
};
39+
}
40+
41+
export default async function Page(props: {
42+
params: Promise<{ slug: string }>;
43+
}) {
44+
const params = await props.params;
45+
const page = blog.getPage([params.slug]);
46+
47+
if (!page) notFound();
48+
const { body: Mdx, toc } = await page.data.load();
49+
50+
return (
51+
<>
52+
<div className="container max-w-4xl pt-12 pb-6 md:px-8">
53+
<Button
54+
variant="outline"
55+
size="sm"
56+
className="mb-4 text-sm text-white/80"
57+
asChild
58+
>
59+
<Link href="/blog">
60+
<ArrowLeft className="mr-1" />
61+
Back
62+
</Link>
63+
</Button>
64+
<h1 className="mb-2 text-4xl font-bold text-white">
65+
{page.data.title}
66+
</h1>
67+
<p className="text-white/80">{page.data.description}</p>
68+
</div>
69+
<article className="container max-w-4xl flex flex-col px-4 lg:flex-row lg:px-8">
70+
<div className="prose min-w-0 flex-1 p-4 pt-0">
71+
{page.data.image && (
72+
<div className="mb-8 mx-auto max-w-2xl overflow-hidden rounded-lg">
73+
<Image
74+
src={page.data.image}
75+
alt={`Cover image for ${page.data.title}`}
76+
width={800}
77+
height={450}
78+
className="w-full object-cover"
79+
priority
80+
/>
81+
</div>
82+
)}
83+
<InlineTOC items={toc} defaultOpen={false} className="mb-6" />
84+
<Mdx
85+
components={{
86+
...defaultMdxComponents,
87+
File,
88+
Files,
89+
Folder,
90+
Tabs,
91+
Tab,
92+
}}
93+
/>
94+
</div>
95+
</article>
96+
</>
97+
);
98+
}
99+
100+
export function generateStaticParams(): { slug: string }[] {
101+
return blog.getPages().map((page) => ({
102+
slug: page.slugs[0] || '',
103+
}));
104+
}

apps/www/app/(home)/blog/page.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { blog } from '@/lib/source';
2+
import { ArrowRight } from "lucide-react";
3+
import {
4+
Card,
5+
CardContent,
6+
CardFooter,
7+
CardHeader,
8+
} from "@/components/ui/card";
9+
import { SubscriptionForm } from '@/components/ui/subscription-form';
10+
import { Metadata } from 'next';
11+
12+
export const metadata: Metadata = {
13+
title: "Blog",
14+
description: "A blog about Amical, Productivity and AI",
15+
}
16+
17+
export default function Page(): React.ReactElement {
18+
const posts = [...blog.getPages()].sort(
19+
(a, b) => {
20+
// First sort by priority (higher priority first)
21+
const priorityDiff = (b.data.priority || 0) - (a.data.priority || 0);
22+
if (priorityDiff !== 0) return priorityDiff;
23+
24+
// Then sort by date (newer first)
25+
return new Date(b.data.date ?? b.file.name).getTime() -
26+
new Date(a.data.date ?? a.file.name).getTime();
27+
}
28+
);
29+
30+
return (
31+
<section className="py-32">
32+
<div className="container mx-auto flex flex-col items-center gap-16 lg:px-16">
33+
<div className="text-center">
34+
<SubscriptionForm
35+
variant="blog"
36+
formName="blog_subscription"
37+
redirectUrl="https://amical.ai/blog?submission=true&form_type=subscribe"
38+
showHeader={true}
39+
/>
40+
</div>
41+
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 lg:gap-8">
42+
{posts.map((post) => (
43+
<Card key={post.url} className="grid grid-rows-[auto_auto_1fr_auto]">
44+
{post.data.image && (
45+
<div className="aspect-[16/9] w-full">
46+
<a
47+
href={post.url}
48+
className="transition-opacity duration-200 fade-in hover:opacity-70"
49+
>
50+
<img
51+
src={post.data.image}
52+
alt={post.data.title}
53+
className="h-full w-full object-cover object-center"
54+
/>
55+
</a>
56+
</div>
57+
)}
58+
<CardHeader>
59+
<h3 className="text-lg font-semibold hover:underline md:text-xl">
60+
<a href={post.url}>
61+
{post.data.title}
62+
</a>
63+
</h3>
64+
</CardHeader>
65+
<CardContent>
66+
<p className="text-muted-foreground">{post.data.description}</p>
67+
</CardContent>
68+
<CardFooter>
69+
<a
70+
href={post.url}
71+
className="flex items-center text-foreground hover:underline"
72+
>
73+
Read more
74+
<ArrowRight className="ml-2 size-4" />
75+
</a>
76+
</CardFooter>
77+
</Card>
78+
))}
79+
</div>
80+
</div>
81+
</section>
82+
);
83+
}

apps/www/lib/source.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1-
import { docs } from '@/.source';
1+
import { docs, meta, blog as blogPosts } from '@/.source';
22
import { loader } from 'fumadocs-core/source';
33
import { icons } from 'lucide-react';
44
import { createElement } from 'react';
5+
import { createMDXSource } from 'fumadocs-mdx';
56

6-
// See https://fumadocs.vercel.app/docs/headless/source-api for more info
77
export const source = loader({
8-
// it assigns a URL to your pages
98
baseUrl: '/docs',
109
icon(icon) {
1110
if (icon && icon in icons)
1211
return createElement(icons[icon as keyof typeof icons]);
1312
},
13+
source: createMDXSource(docs, meta),
14+
});
1415

15-
source: docs.toFumadocsSource(),
16+
export const blog = loader({
17+
baseUrl: '/blog',
18+
source: createMDXSource(blogPosts, meta),
1619
});

apps/www/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
"version": "0.0.0",
44
"private": true,
55
"scripts": {
6-
"build": "pnpm build:sitemap && next build",
6+
"build": "pnpm refresh-content && next build && pnpm build:sitemap",
77
"build:sitemap": "pnpm exec tsx ./scripts/generate-sitemap.mts",
88
"dev": "next dev --turbo",
99
"start": "next start",
1010
"serve": "pnpm dlx serve out -p 3000",
11+
"fetch-content": "pnpm exec tsx ./scripts/fetch-content.mts",
12+
"cleanup-content": "pnpm exec tsx ./scripts/cleanup-content.mts",
13+
"refresh-content": "pnpm cleanup-content && pnpm fetch-content",
1114
"postinstall": "fumadocs-mdx"
1215
},
1316
"dependencies": {
@@ -33,13 +36,16 @@
3336
"tailwind-merge": "^3.2.0"
3437
},
3538
"devDependencies": {
39+
"@aws-sdk/client-s3": "^3.832.0",
3640
"@tailwindcss/postcss": "^4.1.5",
3741
"@types/mdx": "^2.0.13",
3842
"@types/node": "22.15.12",
3943
"@types/react": "^19.1.3",
4044
"@types/react-dom": "^19.1.3",
45+
"dotenv": "^16.5.0",
4146
"globby": "^14.1.0",
4247
"postcss": "^8.5.3",
48+
"rimraf": "^6.0.1",
4349
"server": "^1.0.41",
4450
"tailwindcss": "^4.1.5",
4551
"tsx": "^4.19.4",

0 commit comments

Comments
 (0)