Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 10 additions & 0 deletions apps/www/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# S3 Configuration for fetch-content script
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=us-east-1

# Optional: Override default S3 settings
# S3_BUCKET_NAME=amical-www
# S3_ENDPOINT=https://s3.wasabisys.com
# BLOG_PREFIX=blog/
# BLOG_IMAGES_PREFIX=blog-images/
7 changes: 6 additions & 1 deletion apps/www/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@ yarn-error.log*
next-env.d.ts

# sitemap
/public/sitemap.xml
/public/sitemap.xml

# remote blog content
/content/blogs/*
/public/blog/*
/content/tmp/*
38 changes: 19 additions & 19 deletions apps/www/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,31 @@ yarn dev

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

## Explore
## Content Management

In the project, you can see:
### Fetching Blog Content

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

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

The script will:
- Fetch MDX files from the `blog/` folder in the S3 bucket and save them to `content/blogs/`
- Fetch images from the `blog-images/` folder in the S3 bucket and save them to `public/blog/`

### Fumadocs MDX
For more details, see the [scripts README](./scripts/README.md).

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

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

## Learn More
```bash
pnpm build
```

To learn more about Next.js and Fumadocs, take a look at the following
resources:
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.

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs
To use this in CI/CD environments, make sure to configure the appropriate AWS credentials as environment variables.
35 changes: 35 additions & 0 deletions apps/www/app/(home)/blog/[slug]/page.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';
import { Share } from 'lucide-react';
import {
TooltipContent,
Tooltip,
TooltipTrigger,
} from '@radix-ui/react-tooltip';
import { useState } from 'react';
import { cn } from '@/lib/cn';
import { buttonVariants } from '@/components/ui/button';

export function Control({ url }: { url: string }): React.ReactElement {
const [open, setOpen] = useState(false);
const onClick = (): void => {
setOpen(true);
void navigator.clipboard.writeText(`${window.location.origin}${url}`);
};

return (
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger
className={cn(
buttonVariants({ className: 'gap-2', variant: 'secondary' }),
)}
onClick={onClick}
>
<Share className="size-4" />
Share Post
</TooltipTrigger>
<TooltipContent className="rounded-lg border bg-fd-popover p-2 text-sm text-fd-popover-foreground">
Copied
</TooltipContent>
</Tooltip>
);
}
104 changes: 104 additions & 0 deletions apps/www/app/(home)/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { blog } from '@/lib/source';
import { File, Files, Folder } from 'fumadocs-ui/components/files';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';

export async function generateMetadata(props: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const params = await props.params;
const page = blog.getPage([params.slug]);

if (!page) notFound();

return {
title: {
absolute: `${page.data.title} | Amical`
},
description: page.data.description ?? 'A blog about Amical, Productivity and AI',
openGraph: {
title: `${page.data.title}`,
description: page.data.description ?? 'A blog about Amical, Productivity and AI',
type: 'article',
images: page.data.image ? [{ url: page.data.image }] : undefined,
},
twitter: {
card: 'summary_large_image',
title: `${page.data.title}`,
description: page.data.description ?? 'A blog about Amical, Productivity and AI',
images: page.data.image ? [page.data.image] : undefined,
},
};
}

export default async function Page(props: {
params: Promise<{ slug: string }>;
}) {
const params = await props.params;
const page = blog.getPage([params.slug]);

if (!page) notFound();
const { body: Mdx, toc } = await page.data.load();

return (
<>
<div className="container max-w-4xl pt-12 pb-6 md:px-8">
<Button
variant="outline"
size="sm"
className="mb-4 text-sm text-white/80"
asChild
>
<Link href="/blog">
<ArrowLeft className="mr-1" />
Back
</Link>
</Button>
<h1 className="mb-2 text-4xl font-bold text-white">
{page.data.title}
</h1>
<p className="text-white/80">{page.data.description}</p>
</div>
<article className="container max-w-4xl flex flex-col px-4 lg:flex-row lg:px-8">
<div className="prose min-w-0 flex-1 p-4 pt-0">
{page.data.image && (
<div className="mb-8 mx-auto max-w-2xl overflow-hidden rounded-lg">
<Image
src={page.data.image}
alt={`Cover image for ${page.data.title}`}
width={800}
height={450}
className="w-full object-cover"
priority
/>
</div>
)}
<InlineTOC items={toc} defaultOpen={false} className="mb-6" />
<Mdx
components={{
...defaultMdxComponents,
File,
Files,
Folder,
Tabs,
Tab,
}}
/>
</div>
</article>
</>
);
}

export function generateStaticParams(): { slug: string }[] {
return blog.getPages().map((page) => ({
slug: page.slugs[0] || '',
}));
}
83 changes: 83 additions & 0 deletions apps/www/app/(home)/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { blog } from '@/lib/source';
import { ArrowRight } from "lucide-react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import { SubscriptionForm } from '@/components/ui/subscription-form';
import { Metadata } from 'next';

export const metadata: Metadata = {
title: "Blog",
description: "A blog about Amical, Productivity and AI",
}

export default function Page(): React.ReactElement {
const posts = [...blog.getPages()].sort(
(a, b) => {
// First sort by priority (higher priority first)
const priorityDiff = (b.data.priority || 0) - (a.data.priority || 0);
if (priorityDiff !== 0) return priorityDiff;

// Then sort by date (newer first)
return new Date(b.data.date ?? b.file.name).getTime() -
new Date(a.data.date ?? a.file.name).getTime();
}
);

return (
<section className="py-32">
<div className="container mx-auto flex flex-col items-center gap-16 lg:px-16">
<div className="text-center">
<SubscriptionForm
variant="blog"
formName="blog_subscription"
redirectUrl="https://amical.ai/blog?submission=true&form_type=subscribe"
showHeader={true}
/>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 lg:gap-8">
{posts.map((post) => (
<Card key={post.url} className="grid grid-rows-[auto_auto_1fr_auto]">
{post.data.image && (
<div className="aspect-[16/9] w-full">
<a
href={post.url}
className="transition-opacity duration-200 fade-in hover:opacity-70"
>
<img
src={post.data.image}
alt={post.data.title}
className="h-full w-full object-cover object-center"
/>
</a>
</div>
)}
<CardHeader>
<h3 className="text-lg font-semibold hover:underline md:text-xl">
<a href={post.url}>
{post.data.title}
</a>
</h3>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{post.data.description}</p>
</CardContent>
<CardFooter>
<a
href={post.url}
className="flex items-center text-foreground hover:underline"
>
Read more
<ArrowRight className="ml-2 size-4" />
</a>
</CardFooter>
</Card>
))}
</div>
</div>
</section>
);
}
11 changes: 7 additions & 4 deletions apps/www/lib/source.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { docs } from '@/.source';
import { docs, meta, blog as blogPosts } from '@/.source';
import { loader } from 'fumadocs-core/source';
import { icons } from 'lucide-react';
import { createElement } from 'react';
import { createMDXSource } from 'fumadocs-mdx';

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

source: docs.toFumadocsSource(),
export const blog = loader({
baseUrl: '/blog',
source: createMDXSource(blogPosts, meta),
});
8 changes: 7 additions & 1 deletion apps/www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
"version": "0.0.0",
"private": true,
"scripts": {
"build": "pnpm build:sitemap && next build",
"build": "pnpm refresh-content && next build && pnpm build:sitemap",
"build:sitemap": "pnpm exec tsx ./scripts/generate-sitemap.mts",
"dev": "next dev --turbo",
"start": "next start",
"serve": "pnpm dlx serve out -p 3000",
"fetch-content": "pnpm exec tsx ./scripts/fetch-content.mts",
"cleanup-content": "pnpm exec tsx ./scripts/cleanup-content.mts",
"refresh-content": "pnpm cleanup-content && pnpm fetch-content",
"postinstall": "fumadocs-mdx"
},
"dependencies": {
Expand All @@ -33,13 +36,16 @@
"tailwind-merge": "^3.2.0"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.832.0",
"@tailwindcss/postcss": "^4.1.5",
"@types/mdx": "^2.0.13",
"@types/node": "22.15.12",
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"dotenv": "^16.5.0",
"globby": "^14.1.0",
"postcss": "^8.5.3",
"rimraf": "^6.0.1",
"server": "^1.0.41",
"tailwindcss": "^4.1.5",
"tsx": "^4.19.4",
Expand Down
Loading