mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-11 20:08:57 +00:00
docs: add changelog entries for v1.33.1 through v1.37.0
Add missing changelog entries to content/pages/changelog-page.md: v1.34.0 (2025-12-26): Blog page featured layout with hero post - blogFeatured frontmatter field for posts - Hero card displays first featured post with landscape image - 2-column featured row for remaining featured posts - 3-column grid for regular posts v1.35.0 (2025-12-26): Image support at top of posts and pages - showImageAtTop frontmatter field - Full-width image display above post header - Works for both posts and pages v1.36.0 (2025-12-27): Social footer component - Customizable social links (8 platform types) - Copyright with auto-updating year - showSocialFooter frontmatter field for per-page control - Configurable via siteConfig.socialFooter v1.37.0 (2025-12-27): Newsletter Admin UI - Three-column admin interface at /newsletter-admin - Subscriber management with search and filters - Send newsletter panel (post selection or custom email) - Weekly digest automation (Sunday 9am UTC) - Developer notifications (subscriber alerts, weekly stats) - Markdown-to-HTML conversion for custom emails
This commit is contained in:
File diff suppressed because it is too large
Load Diff
163
FORK_CONFIG.md
163
FORK_CONFIG.md
@@ -481,6 +481,169 @@ homepage: {
|
||||
|
||||
---
|
||||
|
||||
## Newsletter Configuration
|
||||
|
||||
The newsletter feature integrates with AgentMail for email subscriptions and sending. It is disabled by default.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set these in the Convex dashboard:
|
||||
|
||||
| Variable | Description |
|
||||
| -------- | ----------- |
|
||||
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
|
||||
| `AGENTMAIL_INBOX` | Your inbox address (e.g., `newsletter@mail.agentmail.to`) |
|
||||
|
||||
### In fork-config.json
|
||||
|
||||
```json
|
||||
{
|
||||
"newsletter": {
|
||||
"enabled": true,
|
||||
"agentmail": {
|
||||
"inbox": "newsletter@mail.agentmail.to"
|
||||
},
|
||||
"signup": {
|
||||
"home": {
|
||||
"enabled": true,
|
||||
"position": "above-footer",
|
||||
"title": "Stay Updated",
|
||||
"description": "Get new posts delivered to your inbox."
|
||||
},
|
||||
"blogPage": {
|
||||
"enabled": true,
|
||||
"position": "above-footer",
|
||||
"title": "Subscribe",
|
||||
"description": "Get notified when new posts are published."
|
||||
},
|
||||
"posts": {
|
||||
"enabled": true,
|
||||
"position": "below-content",
|
||||
"title": "Enjoyed this post?",
|
||||
"description": "Subscribe for more updates."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Configuration
|
||||
|
||||
In `src/config/siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
newsletter: {
|
||||
enabled: true, // Master switch for newsletter feature
|
||||
agentmail: {
|
||||
inbox: "newsletter@mail.agentmail.to",
|
||||
},
|
||||
signup: {
|
||||
home: {
|
||||
enabled: true,
|
||||
position: "above-footer", // or "below-intro"
|
||||
title: "Stay Updated",
|
||||
description: "Get new posts delivered to your inbox.",
|
||||
},
|
||||
blogPage: {
|
||||
enabled: true,
|
||||
position: "above-footer", // or "below-posts"
|
||||
title: "Subscribe",
|
||||
description: "Get notified when new posts are published.",
|
||||
},
|
||||
posts: {
|
||||
enabled: true,
|
||||
position: "below-content",
|
||||
title: "Enjoyed this post?",
|
||||
description: "Subscribe for more updates.",
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Frontmatter Override
|
||||
|
||||
Hide or show newsletter signup on specific posts using frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My Post
|
||||
newsletter: false # Hide newsletter signup on this post
|
||||
---
|
||||
```
|
||||
|
||||
Or force show it even if posts default is disabled:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Special Offer Post
|
||||
newsletter: true # Show newsletter signup on this post
|
||||
---
|
||||
```
|
||||
|
||||
### Sending Newsletters
|
||||
|
||||
To send a newsletter for a specific post:
|
||||
|
||||
```bash
|
||||
npm run newsletter:send setup-guide
|
||||
```
|
||||
|
||||
Or use the Convex CLI directly:
|
||||
|
||||
```bash
|
||||
npx convex run newsletter:sendPostNewsletter '{"postSlug":"setup-guide","siteUrl":"https://yoursite.com","siteName":"Your Site"}'
|
||||
```
|
||||
|
||||
### Subscriber Management
|
||||
|
||||
View subscriber count on the `/stats` page. Subscribers are stored in the `newsletterSubscribers` table in Convex.
|
||||
|
||||
---
|
||||
|
||||
## Contact Form Configuration
|
||||
|
||||
Enable contact forms on any page or post via frontmatter. Messages are sent via AgentMail.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set these in the Convex dashboard:
|
||||
|
||||
| Variable | Description |
|
||||
| -------- | ----------- |
|
||||
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
|
||||
| `AGENTMAIL_INBOX` | Your inbox address for sending (e.g., `newsletter@mail.agentmail.to`) |
|
||||
| `AGENTMAIL_CONTACT_EMAIL` | Optional: recipient for contact form messages (defaults to AGENTMAIL_INBOX) |
|
||||
|
||||
### Site Config
|
||||
|
||||
In `src/config/siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
contactForm: {
|
||||
enabled: true, // Global toggle for contact form feature
|
||||
title: "Get in Touch",
|
||||
description: "Send us a message and we'll get back to you.",
|
||||
},
|
||||
```
|
||||
|
||||
**Note:** Recipient email is configured via Convex environment variables (`AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX`). Never hardcode email addresses in code.
|
||||
|
||||
### Frontmatter Usage
|
||||
|
||||
Enable contact form on any page or post:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Contact Us
|
||||
slug: contact
|
||||
contactForm: true
|
||||
---
|
||||
```
|
||||
|
||||
The form includes name, email, and message fields. Submissions are stored in Convex and sent via AgentMail to the configured recipient.
|
||||
|
||||
---
|
||||
|
||||
## AI Agent Prompt
|
||||
|
||||
Copy this prompt to have an AI agent apply all changes:
|
||||
|
||||
11
TASK.md
11
TASK.md
@@ -7,10 +7,19 @@
|
||||
|
||||
## Current Status
|
||||
|
||||
v1.35.0 ready. Added `showImageAtTop` frontmatter field to display images at the top of posts and pages above the header. Image appears full-width when enabled, otherwise only used for Open Graph and featured cards.
|
||||
v1.38.0 ready. Improved newsletter CLI commands - `newsletter:send` now calls mutation directly and added `newsletter:send:stats` for sending weekly stats summary. Created blog post "How to use AgentMail with Markdown Sync" with complete setup guide.
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Newsletter CLI improvements
|
||||
- [x] Updated newsletter:send to call scheduleSendPostNewsletter mutation directly
|
||||
- [x] Added newsletter:send:stats command for weekly stats summary
|
||||
- [x] Created scheduleSendStatsSummary mutation in convex/newsletter.ts
|
||||
- [x] Created send-newsletter-stats.ts script
|
||||
- [x] Verified all AgentMail features use environment variables (no hardcoded emails)
|
||||
- [x] Updated documentation (docs.md, files.md, changelog.md, changelog-page.md, TASK.md)
|
||||
- [x] Created blog post "How to use AgentMail with Markdown Sync"
|
||||
|
||||
- [x] showImageAtTop frontmatter field for posts and pages
|
||||
- [x] Added showImageAtTop optional boolean field to convex/schema.ts for posts and pages
|
||||
- [x] Updated scripts/sync-posts.ts to parse showImageAtTop from frontmatter
|
||||
|
||||
113
changelog.md
113
changelog.md
@@ -4,6 +4,119 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [1.38.0] - 2025-12-27
|
||||
|
||||
### Added
|
||||
|
||||
- Newsletter CLI improvements
|
||||
- `newsletter:send` now calls `scheduleSendPostNewsletter` mutation directly
|
||||
- New `newsletter:send:stats` command to send weekly stats summary
|
||||
- Both commands provide clear success/error feedback
|
||||
- New mutation `scheduleSendStatsSummary` for CLI stats sending
|
||||
- Blog post: "How to use AgentMail with Markdown Sync" with complete setup guide
|
||||
|
||||
### Changed
|
||||
|
||||
- `scripts/send-newsletter.ts`: Now calls mutation directly instead of printing instructions
|
||||
- `convex/newsletter.ts`: Added `scheduleSendStatsSummary` mutation
|
||||
|
||||
### Technical
|
||||
|
||||
- New script: `scripts/send-newsletter-stats.ts`
|
||||
- All AgentMail features verified to use environment variables (no hardcoded emails)
|
||||
|
||||
## [1.37.0] - 2025-12-27
|
||||
|
||||
### Added
|
||||
|
||||
- Newsletter Admin UI at `/newsletter-admin`
|
||||
- Three-column layout similar to Write page
|
||||
- View all subscribers with search and filter (all/active/unsubscribed)
|
||||
- Stats showing active, total, and sent newsletter counts
|
||||
- Delete subscribers directly from admin
|
||||
- Send newsletter panel with two modes:
|
||||
- Send Post: Select a blog post to send as newsletter
|
||||
- Write Email: Compose custom email with markdown support
|
||||
- Markdown-to-HTML conversion for custom emails (headers, bold, italic, links, lists)
|
||||
- Copy icon on success messages to copy CLI commands
|
||||
- Theme-aware success/error styling (no hardcoded green)
|
||||
- Recent newsletters list showing sent history
|
||||
- Configurable via `siteConfig.newsletterAdmin`
|
||||
- Weekly Digest automation
|
||||
- Cron job runs every Sunday at 9:00 AM UTC
|
||||
- Automatically sends all posts published in the last 7 days
|
||||
- Uses AgentMail SDK for email delivery
|
||||
- Configurable via `siteConfig.weeklyDigest`
|
||||
- Developer Notifications
|
||||
- New subscriber alerts sent via email when someone subscribes
|
||||
- Weekly stats summary sent every Monday at 9:00 AM UTC
|
||||
- Uses `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` as recipient
|
||||
- Configurable via `siteConfig.newsletterNotifications`
|
||||
- Admin queries and mutations for newsletter management
|
||||
- `getAllSubscribers`: Paginated subscriber list with search/filter
|
||||
- `deleteSubscriber`: Remove subscriber from database
|
||||
- `getNewsletterStats`: Stats for admin dashboard
|
||||
- `getPostsForNewsletter`: List of posts with sent status
|
||||
|
||||
### Changed
|
||||
|
||||
- `convex/newsletter.ts`: Added admin queries (getAllSubscribers, deleteSubscriber, getNewsletterStats, getPostsForNewsletter, getStatsForSummary) and scheduleSendCustomNewsletter mutation
|
||||
- `convex/newsletterActions.ts`: Added sendWeeklyDigest, notifyNewSubscriber, sendWeeklyStatsSummary, sendCustomNewsletter actions with markdown-to-HTML conversion
|
||||
- `convex/posts.ts`: Added getRecentPostsInternal query for weekly digest
|
||||
- `convex/crons.ts`: Added weekly digest (Sunday 9am) and stats summary (Monday 9am) cron jobs
|
||||
- `src/config/siteConfig.ts`: Added NewsletterAdminConfig, NewsletterNotificationsConfig, WeeklyDigestConfig interfaces
|
||||
- `src/App.tsx`: Added /newsletter-admin route
|
||||
- `src/styles/global.css`: Added newsletter admin styles with responsive design
|
||||
|
||||
### Technical
|
||||
|
||||
- New page: `src/pages/NewsletterAdmin.tsx`
|
||||
- Newsletter admin hidden from navigation by default (security through obscurity)
|
||||
- All admin features togglable via siteConfig
|
||||
- Uses Convex internal actions for email sending (Node.js runtime with AgentMail SDK)
|
||||
- Cron jobs use environment variables: SITE_URL, SITE_NAME
|
||||
|
||||
## [1.36.0] - 2025-12-27
|
||||
|
||||
### Added
|
||||
|
||||
- Social footer component with customizable social links and copyright
|
||||
- Displays social icons on the left (GitHub, Twitter/X, LinkedIn, and more)
|
||||
- Shows copyright symbol, site name, and auto-updating year on the right
|
||||
- Configurable via `siteConfig.socialFooter` in `src/config/siteConfig.ts`
|
||||
- Supports 8 platform types: github, twitter, linkedin, instagram, youtube, tiktok, discord, website
|
||||
- Uses Phosphor icons for consistent styling
|
||||
- Appears below the main footer on homepage, blog posts, and pages
|
||||
- Can work independently of the main footer when set via frontmatter
|
||||
- Frontmatter control for social footer visibility
|
||||
- `showSocialFooter` field for posts and pages to override siteConfig defaults
|
||||
- Set `showSocialFooter: false` to hide on specific posts/pages
|
||||
- Works like existing `showFooter` field pattern
|
||||
- Social footer configuration options
|
||||
- `enabled`: Global toggle for social footer
|
||||
- `showOnHomepage`, `showOnPosts`, `showOnPages`, `showOnBlogPage`: Per-location visibility
|
||||
- `socialLinks`: Array of social link objects with platform and URL
|
||||
- `copyright.siteName`: Site/company name for copyright display
|
||||
- `copyright.showYear`: Toggle for auto-updating year
|
||||
|
||||
### Changed
|
||||
|
||||
- `src/config/siteConfig.ts`: Added `SocialLink`, `SocialFooterConfig` interfaces and `socialFooter` configuration
|
||||
- `convex/schema.ts`: Added `showSocialFooter` optional boolean field to posts and pages tables
|
||||
- `convex/posts.ts` and `convex/pages.ts`: Updated queries and mutations to include `showSocialFooter` field
|
||||
- `scripts/sync-posts.ts`: Updated to parse `showSocialFooter` from frontmatter for both posts and pages
|
||||
- `src/pages/Home.tsx`: Added SocialFooter component below Footer
|
||||
- `src/pages/Post.tsx`: Added SocialFooter component below Footer for both posts and pages
|
||||
- `src/pages/Blog.tsx`: Added SocialFooter component below Footer
|
||||
- `src/styles/global.css`: Added social footer styles with flexbox layout and mobile responsive design
|
||||
|
||||
### Technical
|
||||
|
||||
- New component: `src/components/SocialFooter.tsx`
|
||||
- Uses Phosphor icons: GithubLogo, TwitterLogo, LinkedinLogo, InstagramLogo, YoutubeLogo, TiktokLogo, DiscordLogo, Globe
|
||||
- Responsive design: stacks vertically on mobile (max-width: 480px)
|
||||
- Year automatically updates using `new Date().getFullYear()`
|
||||
|
||||
## [1.35.0] - 2025-12-26
|
||||
|
||||
### Added
|
||||
|
||||
240
content/blog/how-to-use-agentmail.md
Normal file
240
content/blog/how-to-use-agentmail.md
Normal file
@@ -0,0 +1,240 @@
|
||||
---
|
||||
title: "How to use AgentMail with Markdown Sync"
|
||||
description: "Complete guide to setting up AgentMail for newsletters and contact forms in your markdown blog"
|
||||
date: "2025-12-27"
|
||||
slug: "how-to-use-agentmail"
|
||||
published: true
|
||||
tags: ["agentmail", "newsletter", "email", "setup"]
|
||||
---
|
||||
|
||||
AgentMail provides email infrastructure for your markdown blog, enabling newsletter subscriptions, contact forms, and automated email notifications. This guide covers setup, configuration, and usage.
|
||||
|
||||
## What is AgentMail
|
||||
|
||||
AgentMail is an email service designed for AI agents and developers. It handles email sending and receiving without OAuth or MFA requirements, making it ideal for automated workflows.
|
||||
|
||||
For this markdown blog framework, AgentMail powers:
|
||||
|
||||
- Newsletter subscriptions and sending
|
||||
- Contact forms on posts and pages
|
||||
- Developer notifications for new subscribers
|
||||
- Weekly digest emails
|
||||
- Weekly stats summaries
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create an AgentMail account
|
||||
|
||||
Sign up at [agentmail.to](https://agentmail.to) and create an inbox. Your inbox address will look like `yourname@agentmail.to`.
|
||||
|
||||
### 2. Get your API key
|
||||
|
||||
In the AgentMail dashboard, navigate to API settings and copy your API key. You'll need this for Convex environment variables.
|
||||
|
||||
### 3. Configure Convex environment variables
|
||||
|
||||
In your Convex dashboard, go to Settings > Environment Variables and add:
|
||||
|
||||
| Variable | Description | Required |
|
||||
|----------|-------------|----------|
|
||||
| `AGENTMAIL_API_KEY` | Your AgentMail API key | Yes |
|
||||
| `AGENTMAIL_INBOX` | Your inbox address (e.g., `markdown@agentmail.to`) | Yes |
|
||||
| `AGENTMAIL_CONTACT_EMAIL` | Contact form recipient (defaults to inbox if not set) | No |
|
||||
|
||||
**Important:** Never hardcode email addresses in your code. Always use environment variables.
|
||||
|
||||
### 4. Enable features in siteConfig
|
||||
|
||||
Edit `src/config/siteConfig.ts` to enable newsletter and contact form features:
|
||||
|
||||
```typescript
|
||||
newsletter: {
|
||||
enabled: true,
|
||||
showOnHomepage: true,
|
||||
showOnBlogPage: true,
|
||||
showOnPosts: true,
|
||||
title: "Subscribe to the newsletter",
|
||||
description: "Get updates delivered to your inbox",
|
||||
},
|
||||
|
||||
contactForm: {
|
||||
enabled: true,
|
||||
title: "Get in touch",
|
||||
description: "Send us a message",
|
||||
},
|
||||
```
|
||||
|
||||
## Newsletter features
|
||||
|
||||
### Subscriber management
|
||||
|
||||
The Newsletter Admin page at `/newsletter-admin` provides:
|
||||
|
||||
- View all subscribers with search and filters
|
||||
- Delete subscribers
|
||||
- Send blog posts as newsletters
|
||||
- Write and send custom emails with markdown support
|
||||
- View email statistics dashboard
|
||||
- Track recent sends (last 10)
|
||||
|
||||
### Sending newsletters
|
||||
|
||||
**Via CLI:**
|
||||
|
||||
```bash
|
||||
# Send a specific post to all subscribers
|
||||
npm run newsletter:send setup-guide
|
||||
|
||||
# Send weekly stats summary to your inbox
|
||||
npm run newsletter:send:stats
|
||||
```
|
||||
|
||||
**Via Admin UI:**
|
||||
|
||||
1. Navigate to `/newsletter-admin`
|
||||
2. Select "Send Post" or "Write Email" from the sidebar
|
||||
3. Choose a post or compose a custom email
|
||||
4. Click "Send Newsletter"
|
||||
|
||||
### Weekly digest
|
||||
|
||||
Automated weekly digest emails are sent every Sunday at 9:00 AM UTC. They include all posts published in the last 7 days.
|
||||
|
||||
Configure in `siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
weeklyDigest: {
|
||||
enabled: true,
|
||||
},
|
||||
```
|
||||
|
||||
### Developer notifications
|
||||
|
||||
Receive email notifications when:
|
||||
|
||||
- A new subscriber signs up
|
||||
- Weekly stats summary (every Monday at 9:00 AM UTC)
|
||||
|
||||
Configure in `siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
newsletterNotifications: {
|
||||
enabled: true,
|
||||
},
|
||||
```
|
||||
|
||||
Notifications are sent to `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` if contact email is not set.
|
||||
|
||||
## Contact forms
|
||||
|
||||
### Enable on posts and pages
|
||||
|
||||
Add `contactForm: true` to any post or page frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Contact Us"
|
||||
slug: "contact"
|
||||
published: true
|
||||
contactForm: true
|
||||
---
|
||||
|
||||
Your page content here...
|
||||
```
|
||||
|
||||
The contact form includes:
|
||||
|
||||
- Name field
|
||||
- Email field
|
||||
- Message field
|
||||
|
||||
Submissions are stored in Convex and sent via AgentMail to your configured recipient.
|
||||
|
||||
### Frontmatter options
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `contactForm` | boolean | Enable contact form on this post/page |
|
||||
|
||||
## Frontmatter options
|
||||
|
||||
### Newsletter signup
|
||||
|
||||
Control newsletter signup display per post/page:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "My Post"
|
||||
newsletter: true # Show signup (default: follows siteConfig)
|
||||
---
|
||||
```
|
||||
|
||||
Or hide it:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "My Post"
|
||||
newsletter: false # Hide signup even if enabled globally
|
||||
---
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
All AgentMail features require these Convex environment variables:
|
||||
|
||||
**Required:**
|
||||
|
||||
- `AGENTMAIL_API_KEY` - Your AgentMail API key
|
||||
- `AGENTMAIL_INBOX` - Your inbox address
|
||||
|
||||
**Optional:**
|
||||
|
||||
- `AGENTMAIL_CONTACT_EMAIL` - Contact form recipient (defaults to inbox)
|
||||
|
||||
**Note:** If environment variables are not configured, users will see: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX."
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run newsletter:send <slug>` | Send a blog post to all subscribers |
|
||||
| `npm run newsletter:send:stats` | Send weekly stats summary to your inbox |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Emails not sending:**
|
||||
|
||||
1. Verify `AGENTMAIL_API_KEY` and `AGENTMAIL_INBOX` are set in Convex dashboard
|
||||
2. Check Convex function logs for error messages
|
||||
3. Ensure your inbox is active in AgentMail dashboard
|
||||
|
||||
**Contact form not appearing:**
|
||||
|
||||
1. Verify `contactForm: true` is in frontmatter
|
||||
2. Check `siteConfig.contactForm.enabled` is `true`
|
||||
3. Run `npm run sync` to sync frontmatter changes
|
||||
|
||||
**Newsletter Admin not accessible:**
|
||||
|
||||
1. Verify `siteConfig.newsletterAdmin.enabled` is `true`
|
||||
2. Navigate to `/newsletter-admin` directly (hidden from nav by default)
|
||||
|
||||
## Resources
|
||||
|
||||
- [AgentMail Documentation](https://docs.agentmail.to)
|
||||
- [AgentMail Quickstart](https://docs.agentmail.to/quickstart)
|
||||
- [AgentMail Sending & Receiving Email](https://docs.agentmail.to/sending-receiving-email)
|
||||
- [AgentMail Inboxes](https://docs.agentmail.to/inboxes)
|
||||
|
||||
## Summary
|
||||
|
||||
AgentMail integration provides:
|
||||
|
||||
- Newsletter subscriptions and sending
|
||||
- Contact forms on any post or page
|
||||
- Automated weekly digests
|
||||
- Developer notifications
|
||||
- Admin UI for subscriber management
|
||||
- CLI tools for sending newsletters and stats
|
||||
|
||||
All features use Convex environment variables for configuration. No hardcoded emails in your codebase.
|
||||
64
content/blog/how-to-use-firecrawl.md
Normal file
64
content/blog/how-to-use-firecrawl.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "How to use Firecrawl"
|
||||
description: "Import external articles as markdown posts using Firecrawl. Get your API key and configure environment variables for local imports and AI chat."
|
||||
date: "2025-01-20"
|
||||
slug: "how-to-use-firecrawl"
|
||||
published: true
|
||||
tags: ["tutorial", "firecrawl", "import"]
|
||||
---
|
||||
|
||||
# How to use Firecrawl
|
||||
|
||||
You found an article you want to republish or reference. Copying content manually takes time. Firecrawl scrapes web pages and converts them to markdown automatically.
|
||||
|
||||
## What it is
|
||||
|
||||
Firecrawl is a web scraping service that turns any URL into clean markdown. This app uses it in two places: the import script for creating draft posts, and the AI chat feature for fetching page content.
|
||||
|
||||
## Who it's for
|
||||
|
||||
Developers who want to import external articles without manual copying. If you republish content or need to reference external sources, Firecrawl saves time.
|
||||
|
||||
## The problem it solves
|
||||
|
||||
Manually copying content from websites is slow. You copy text, fix formatting, add frontmatter, and handle images. Firecrawl does this automatically.
|
||||
|
||||
## How it works
|
||||
|
||||
The import script scrapes a URL, extracts the title and description, converts HTML to markdown, and creates a draft post in `content/blog/`. The AI chat feature uses Firecrawl to fetch page content when you share URLs in conversations.
|
||||
|
||||
## How to try it
|
||||
|
||||
**Step 1: Get your API key**
|
||||
|
||||
Visit [firecrawl.dev](https://firecrawl.dev) and sign up. Copy your API key. It starts with `fc-`.
|
||||
|
||||
**Step 2: Set up local imports**
|
||||
|
||||
Add the key to `.env.local` in your project root:
|
||||
|
||||
```
|
||||
FIRECRAWL_API_KEY=fc-your-api-key-here
|
||||
```
|
||||
|
||||
Now you can import articles:
|
||||
|
||||
```bash
|
||||
npm run import https://example.com/article
|
||||
```
|
||||
|
||||
This creates a draft post in `content/blog/`. Review it, set `published: true`, then run `npm run sync`.
|
||||
|
||||
**Step 3: Enable AI chat scraping**
|
||||
|
||||
If you use the AI chat feature, set the same key in your Convex Dashboard:
|
||||
|
||||
1. Go to [dashboard.convex.dev](https://dashboard.convex.dev)
|
||||
2. Select your project
|
||||
3. Open Settings > Environment Variables
|
||||
4. Add `FIRECRAWL_API_KEY` with your key value
|
||||
5. Deploy: `npx convex deploy`
|
||||
|
||||
The AI chat can now fetch content from URLs you share.
|
||||
|
||||
That's it. One API key, two places to set it, and you're done.
|
||||
@@ -8,6 +8,7 @@ tags: ["convex", "netlify", "tutorial", "deployment"]
|
||||
readTime: "8 min read"
|
||||
featured: true
|
||||
featuredOrder: 6
|
||||
newsletter: true
|
||||
layout: "sidebar"
|
||||
image: "/images/setupguide.png"
|
||||
authorName: "Markdown"
|
||||
@@ -62,6 +63,7 @@ This guide walks you through forking [this markdown framework](https://github.co
|
||||
- [Visitor Map](#visitor-map)
|
||||
- [Logo Gallery](#logo-gallery)
|
||||
- [Blog page](#blog-page)
|
||||
- [Hardcoded Navigation Items](#hardcoded-navigation-items)
|
||||
- [Scroll-to-top button](#scroll-to-top-button)
|
||||
- [Change the Default Theme](#change-the-default-theme)
|
||||
- [Change the Font](#change-the-font)
|
||||
@@ -83,6 +85,7 @@ This guide walks you through forking [this markdown framework](https://github.co
|
||||
- [Build failures on Netlify](#build-failures-on-netlify)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Write Page](#write-page)
|
||||
- [AI Agent chat](#ai-agent-chat)
|
||||
- [Next Steps](#next-steps)
|
||||
|
||||
## Prerequisites
|
||||
@@ -187,6 +190,7 @@ export default defineSchema({
|
||||
Blog posts live in `content/blog/` as markdown files. Sync them to Convex:
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
npm run sync # Sync markdown content
|
||||
npm run sync:discovery # Update discovery files (AGENTS.md, llms.txt)
|
||||
@@ -194,6 +198,7 @@ npm run sync:all # Sync content + discovery files together
|
||||
```
|
||||
|
||||
**Production:**
|
||||
|
||||
```bash
|
||||
npm run sync:prod # Sync markdown content
|
||||
npm run sync:discovery:prod # Update discovery files
|
||||
@@ -330,21 +335,21 @@ Your markdown content here...
|
||||
|
||||
### Frontmatter Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | Short description for SEO |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | Yes | URL path (must be unique) |
|
||||
| `published` | Yes | Set to `true` to publish |
|
||||
| `tags` | Yes | Array of topic tags |
|
||||
| `readTime` | No | Estimated reading time |
|
||||
| `image` | No | Header/Open Graph image URL |
|
||||
| `excerpt` | No | Short excerpt for card view |
|
||||
| `featured` | No | Set `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured section (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | Short description for SEO |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | Yes | URL path (must be unique) |
|
||||
| `published` | Yes | Set to `true` to publish |
|
||||
| `tags` | Yes | Array of topic tags |
|
||||
| `readTime` | No | Estimated reading time |
|
||||
| `image` | No | Header/Open Graph image URL |
|
||||
| `excerpt` | No | Short excerpt for card view |
|
||||
| `featured` | No | Set `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured section (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
|
||||
|
||||
### How Frontmatter Works
|
||||
@@ -409,6 +414,7 @@ The `npm run sync` command only syncs markdown text content. Images are deployed
|
||||
After adding or editing posts, sync to Convex.
|
||||
|
||||
**Development sync:**
|
||||
|
||||
```bash
|
||||
npm run sync # Sync markdown content
|
||||
npm run sync:discovery # Update discovery files
|
||||
@@ -426,6 +432,7 @@ VITE_CONVEX_URL=https://your-prod-deployment.convex.cloud
|
||||
Get your production URL from the [Convex Dashboard](https://dashboard.convex.dev) by selecting your project and switching to the Production deployment.
|
||||
|
||||
Then sync:
|
||||
|
||||
```bash
|
||||
npm run sync:prod # Sync markdown content
|
||||
npm run sync:discovery:prod # Update discovery files
|
||||
@@ -443,17 +450,17 @@ Both files are gitignored. Each developer creates their own local environment fi
|
||||
|
||||
### When to Sync vs Deploy
|
||||
|
||||
| What you're changing | Command | Timing |
|
||||
| -------------------------------- | -------------------------- | -------------------- |
|
||||
| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) |
|
||||
| What you're changing | Command | Timing |
|
||||
| -------------------------------- | -------------------------- | ----------------------- |
|
||||
| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) |
|
||||
| Site config changes | `npm run sync:discovery` | Updates discovery files |
|
||||
| Import external URL | `npm run import` then sync | Instant (no rebuild) |
|
||||
| Images in `public/images/` | Git commit + push | Requires rebuild |
|
||||
| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild |
|
||||
| Logo gallery config | Redeploy | Requires rebuild |
|
||||
| React components/styles | Redeploy | Requires rebuild |
|
||||
| Import external URL | `npm run import` then sync | Instant (no rebuild) |
|
||||
| Images in `public/images/` | Git commit + push | Requires rebuild |
|
||||
| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild |
|
||||
| Logo gallery config | Redeploy | Requires rebuild |
|
||||
| React components/styles | Redeploy | Requires rebuild |
|
||||
|
||||
**Markdown content** syncs instantly via Convex. **Images and source code** require pushing to GitHub for Netlify to rebuild.
|
||||
|
||||
@@ -971,15 +978,12 @@ body {
|
||||
serif;
|
||||
|
||||
/* Monospace */
|
||||
font-family:
|
||||
"IBM Plex Mono",
|
||||
"Liberation Mono",
|
||||
ui-monospace,
|
||||
monospace;
|
||||
font-family: "IBM Plex Mono", "Liberation Mono", ui-monospace, monospace;
|
||||
}
|
||||
```
|
||||
|
||||
Available font options:
|
||||
|
||||
- `serif`: New York serif font (default)
|
||||
- `sans`: System sans-serif fonts
|
||||
- `monospace`: IBM Plex Mono monospace font
|
||||
@@ -1103,6 +1107,49 @@ How it works:
|
||||
- A cron job cleans up stale sessions every 5 minutes
|
||||
- No personal data is stored (only anonymous UUIDs)
|
||||
|
||||
## Newsletter Admin
|
||||
|
||||
A newsletter management interface is available at `/newsletter-admin`. Use it to view subscribers, send newsletters, and compose custom emails.
|
||||
|
||||
**Features:**
|
||||
|
||||
- View and search all subscribers with filtering options (search bar in header)
|
||||
- Delete subscribers from the admin UI
|
||||
- Send published blog posts as newsletters
|
||||
- Write custom emails using markdown formatting
|
||||
- View recent newsletter sends (last 10, tracks both posts and custom emails)
|
||||
- Email statistics dashboard with comprehensive metrics
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Enable in `src/config/siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
newsletterAdmin: {
|
||||
enabled: true,
|
||||
showInNav: false, // Keep hidden, access via direct URL
|
||||
},
|
||||
```
|
||||
|
||||
2. Set environment variables in Convex Dashboard:
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------- | ------------------------------------ |
|
||||
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
|
||||
| `AGENTMAIL_INBOX` | Your AgentMail inbox address |
|
||||
| `AGENTMAIL_CONTACT_EMAIL` | Optional recipient for contact forms |
|
||||
|
||||
**Important:** If environment variables are not configured, users will see an error message when attempting to use newsletter or contact form features: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX."
|
||||
|
||||
**Sending newsletters:**
|
||||
|
||||
Two modes are available:
|
||||
|
||||
1. **Send Post**: Select a blog post to send to all active subscribers
|
||||
2. **Write Email**: Compose custom content with markdown support
|
||||
|
||||
The admin UI shows send results and provides CLI commands as alternatives.
|
||||
|
||||
## Mobile Navigation
|
||||
|
||||
On mobile and tablet screens (under 768px), a hamburger menu provides navigation. The menu slides out from the left with keyboard navigation (Escape to close) and a focus trap for accessibility. It auto-closes when you navigate to a new route.
|
||||
@@ -1111,23 +1158,23 @@ On mobile and tablet screens (under 768px), a hamburger menu provides navigation
|
||||
|
||||
Each post and page includes a share dropdown with options for AI tools:
|
||||
|
||||
| Option | Description |
|
||||
| -------------------- | ------------------------------------------------- |
|
||||
| Copy page | Copies formatted markdown to clipboard |
|
||||
| Open in ChatGPT | Opens ChatGPT with raw markdown URL |
|
||||
| Open in Claude | Opens Claude with raw markdown URL |
|
||||
| Open in Perplexity | Opens Perplexity with raw markdown URL |
|
||||
| View as Markdown | Opens raw `.md` file in new tab |
|
||||
| Download as SKILL.md | Downloads skill file for AI agent training |
|
||||
| Option | Description |
|
||||
| -------------------- | ------------------------------------------ |
|
||||
| Copy page | Copies formatted markdown to clipboard |
|
||||
| Open in ChatGPT | Opens ChatGPT with raw markdown URL |
|
||||
| Open in Claude | Opens Claude with raw markdown URL |
|
||||
| Open in Perplexity | Opens Perplexity with raw markdown URL |
|
||||
| View as Markdown | Opens raw `.md` file in new tab |
|
||||
| Download as SKILL.md | Downloads skill file for AI agent training |
|
||||
|
||||
**Git push required for AI links:** The "Open in ChatGPT," "Open in Claude," and "Open in Perplexity" options use GitHub raw URLs to fetch content. For these to work, your content must be pushed to GitHub with `git push`. The `npm run sync` command syncs content to Convex for your live site, but AI services fetch directly from GitHub.
|
||||
|
||||
| What you want | Command needed |
|
||||
| ------------------------------------ | ------------------------------ |
|
||||
| Content visible on your site | `npm run sync` or `sync:prod` |
|
||||
| What you want | Command needed |
|
||||
| ------------------------------------ | ------------------------------------------------- |
|
||||
| Content visible on your site | `npm run sync` or `sync:prod` |
|
||||
| Discovery files updated | `npm run sync:discovery` or `sync:discovery:prod` |
|
||||
| AI links (ChatGPT/Claude/Perplexity) | `git push` to GitHub |
|
||||
| Both content and discovery | `npm run sync:all` or `sync:all:prod` |
|
||||
| AI links (ChatGPT/Claude/Perplexity) | `git push` to GitHub |
|
||||
| Both content and discovery | `npm run sync:all` or `sync:all:prod` |
|
||||
|
||||
**Download as SKILL.md** formats the content as an Anthropic Agent Skills file with metadata, triggers, and instructions sections.
|
||||
|
||||
@@ -1325,7 +1372,7 @@ Enable Agent in the right sidebar on individual posts or pages using the `aiChat
|
||||
---
|
||||
title: "My Post"
|
||||
rightSidebar: true
|
||||
aiChat: true # Enable Agent in right sidebar
|
||||
aiChat: true # Enable Agent in right sidebar
|
||||
---
|
||||
```
|
||||
|
||||
|
||||
@@ -9,6 +9,161 @@ layout: "sidebar"
|
||||
All notable changes to this project.
|
||||

|
||||
|
||||
## v1.38.0
|
||||
|
||||
Released December 27, 2025
|
||||
|
||||
**Newsletter CLI improvements**
|
||||
|
||||
- `newsletter:send` now calls `scheduleSendPostNewsletter` mutation directly
|
||||
- Sends emails in the background instead of printing instructions
|
||||
- Provides clear success/error feedback
|
||||
- Shows helpful messages about checking Newsletter Admin for results
|
||||
- New `newsletter:send:stats` command
|
||||
- Sends weekly stats summary to your inbox on demand
|
||||
- Uses `scheduleSendStatsSummary` mutation
|
||||
- Email sent to AGENTMAIL_INBOX or AGENTMAIL_CONTACT_EMAIL
|
||||
- New mutation `scheduleSendStatsSummary` in `convex/newsletter.ts`
|
||||
- Allows CLI to trigger stats summary sending
|
||||
- Schedules `sendWeeklyStatsSummary` internal action
|
||||
|
||||
**Documentation**
|
||||
|
||||
- Blog post: "How to use AgentMail with Markdown Sync"
|
||||
- Complete setup guide for AgentMail integration
|
||||
- Environment variables configuration
|
||||
- Newsletter and contact form features
|
||||
- CLI commands documentation
|
||||
- Troubleshooting section
|
||||
- Updated docs.md with new CLI commands
|
||||
- Updated files.md with new script reference
|
||||
- Verified all AgentMail features use environment variables (no hardcoded emails)
|
||||
|
||||
Updated files: `scripts/send-newsletter.ts`, `scripts/send-newsletter-stats.ts`, `convex/newsletter.ts`, `package.json`, `content/blog/how-to-use-agentmail.md`, `content/pages/docs.md`, `files.md`, `changelog.md`, `content/pages/changelog-page.md`, `TASK.md`
|
||||
|
||||
## v1.37.0
|
||||
|
||||
Released December 27, 2025
|
||||
|
||||
**Newsletter Admin UI**
|
||||
|
||||
- Newsletter Admin UI at `/newsletter-admin`
|
||||
- Three-column layout similar to Write page
|
||||
- View all subscribers with search and filter (all/active/unsubscribed)
|
||||
- Stats showing active, total, and sent newsletter counts
|
||||
- Delete subscribers directly from admin
|
||||
- Send newsletter panel with two modes:
|
||||
- Send Post: Select a blog post to send as newsletter
|
||||
- Write Email: Compose custom email with markdown support
|
||||
- Markdown-to-HTML conversion for custom emails (headers, bold, italic, links, lists)
|
||||
- Copy icon on success messages to copy CLI commands
|
||||
- Theme-aware success/error styling (no hardcoded green)
|
||||
- Recent newsletters list showing sent history
|
||||
- Configurable via `siteConfig.newsletterAdmin`
|
||||
|
||||
**Weekly Digest automation**
|
||||
|
||||
- Cron job runs every Sunday at 9:00 AM UTC
|
||||
- Automatically sends all posts published in the last 7 days
|
||||
- Uses AgentMail SDK for email delivery
|
||||
- Configurable via `siteConfig.weeklyDigest`
|
||||
|
||||
**Developer Notifications**
|
||||
|
||||
- New subscriber alerts sent via email when someone subscribes
|
||||
- Weekly stats summary sent every Monday at 9:00 AM UTC
|
||||
- Uses `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` as recipient
|
||||
- Configurable via `siteConfig.newsletterNotifications`
|
||||
|
||||
**Admin queries and mutations**
|
||||
|
||||
- `getAllSubscribers`: Paginated subscriber list with search/filter
|
||||
- `deleteSubscriber`: Remove subscriber from database
|
||||
- `getNewsletterStats`: Stats for admin dashboard
|
||||
- `getPostsForNewsletter`: List of posts with sent status
|
||||
|
||||
Updated files: `convex/newsletter.ts`, `convex/newsletterActions.ts`, `convex/posts.ts`, `convex/crons.ts`, `src/config/siteConfig.ts`, `src/App.tsx`, `src/styles/global.css`, `src/pages/NewsletterAdmin.tsx`
|
||||
|
||||
## v1.36.0
|
||||
|
||||
Released December 27, 2025
|
||||
|
||||
**Social footer component**
|
||||
|
||||
- Social footer component with customizable social links and copyright
|
||||
- Displays social icons on the left (GitHub, Twitter/X, LinkedIn, and more)
|
||||
- Shows copyright symbol, site name, and auto-updating year on the right
|
||||
- Configurable via `siteConfig.socialFooter` in `src/config/siteConfig.ts`
|
||||
- Supports 8 platform types: github, twitter, linkedin, instagram, youtube, tiktok, discord, website
|
||||
- Uses Phosphor icons for consistent styling
|
||||
- Appears below the main footer on homepage, blog posts, and pages
|
||||
- Can work independently of the main footer when set via frontmatter
|
||||
|
||||
**Frontmatter control for social footer**
|
||||
|
||||
- `showSocialFooter` field for posts and pages to override siteConfig defaults
|
||||
- Set `showSocialFooter: false` to hide on specific posts/pages
|
||||
- Works like existing `showFooter` field pattern
|
||||
|
||||
**Social footer configuration options**
|
||||
|
||||
- `enabled`: Global toggle for social footer
|
||||
- `showOnHomepage`, `showOnPosts`, `showOnPages`, `showOnBlogPage`: Per-location visibility
|
||||
- `socialLinks`: Array of social link objects with platform and URL
|
||||
- `copyright.siteName`: Site/company name for copyright display
|
||||
- `copyright.showYear`: Toggle for auto-updating year
|
||||
|
||||
Updated files: `src/config/siteConfig.ts`, `convex/schema.ts`, `convex/posts.ts`, `convex/pages.ts`, `scripts/sync-posts.ts`, `src/pages/Home.tsx`, `src/pages/Post.tsx`, `src/pages/Blog.tsx`, `src/styles/global.css`, `src/components/SocialFooter.tsx`
|
||||
|
||||
## v1.35.0
|
||||
|
||||
Released December 26, 2025
|
||||
|
||||
**Image support at top of posts and pages**
|
||||
|
||||
- `showImageAtTop` frontmatter field for posts and pages
|
||||
- Set `showImageAtTop: true` to display the `image` field at the top of the post/page above the header
|
||||
- Image displays full-width with rounded corners above the post header
|
||||
- Default behavior: if `showImageAtTop` is not set or `false`, image only used for Open Graph previews and featured card thumbnails
|
||||
- Works for both blog posts and static pages
|
||||
- Image appears above the post header when enabled
|
||||
|
||||
Updated files: `convex/schema.ts`, `scripts/sync-posts.ts`, `convex/posts.ts`, `convex/pages.ts`, `src/pages/Post.tsx`, `src/pages/Write.tsx`, `src/styles/global.css`
|
||||
|
||||
Documentation updated: `content/pages/docs.md`, `content/blog/how-to-publish.md`, `content/blog/using-images-in-posts.md`, `files.md`
|
||||
|
||||
## v1.34.0
|
||||
|
||||
Released December 26, 2025
|
||||
|
||||
**Blog page featured layout with hero post**
|
||||
|
||||
- `blogFeatured` frontmatter field for posts to mark as featured on blog page
|
||||
- First `blogFeatured` post displays as hero card with landscape image, tags, date, title, excerpt, author info, and read more link
|
||||
- Remaining `blogFeatured` posts display in 2-column featured row with excerpts
|
||||
- Regular (non-featured) posts display in 3-column grid without excerpts
|
||||
- New `BlogHeroCard` component (`src/components/BlogHeroCard.tsx`) for hero display
|
||||
- New `getBlogFeaturedPosts` query returns all published posts with `blogFeatured: true` sorted by date
|
||||
- `PostList` component updated with `columns` prop (2 or 3) and `showExcerpts` prop
|
||||
- Card images use 16:10 landscape aspect ratio
|
||||
- Footer support on blog page via `siteConfig.footer.showOnBlogPage`
|
||||
|
||||
Updated files: `convex/schema.ts`, `convex/posts.ts`, `scripts/sync-posts.ts`, `src/pages/Blog.tsx`, `src/components/PostList.tsx`, `src/styles/global.css`
|
||||
|
||||
## v1.33.1
|
||||
|
||||
Released December 26, 2025
|
||||
|
||||
**Article centering in sidebar layouts**
|
||||
|
||||
- Article content now centers in the middle column when sidebars are present
|
||||
- Left sidebar stays flush left, right sidebar stays flush right
|
||||
- Article uses `margin-left: auto; margin-right: auto` within its `1fr` grid column
|
||||
- Works with both two-column (left sidebar only) and three-column (both sidebars) layouts
|
||||
- Consistent `max-width: 800px` for article content across all sidebar configurations
|
||||
|
||||
Updated files: `src/styles/global.css`
|
||||
|
||||
## v1.33.0
|
||||
|
||||
Released December 26, 2025
|
||||
|
||||
@@ -2,42 +2,17 @@
|
||||
title: "Contact"
|
||||
slug: "contact"
|
||||
published: true
|
||||
contactForm: false
|
||||
newsletter: false
|
||||
order: 4
|
||||
---
|
||||
|
||||
You found the contact page. Nice
|
||||
|
||||
<!-- contactform -->
|
||||
|
||||
## The technical way
|
||||
|
||||
This site runs on Convex, which means every page view is a live subscription to the database. You are not reading cached HTML. You are reading data that synced moments ago.
|
||||
|
||||
If you want to reach out, here is an idea: fork this repo, add a contact form, wire it to a Convex mutation, and deploy. Your message will hit the database in under 100ms. No email server required.
|
||||
|
||||
```typescript
|
||||
// A contact form mutation looks like this
|
||||
export const submitContact = mutation({
|
||||
args: {
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
message: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.insert("messages", {
|
||||
...args,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## The human way
|
||||
|
||||
Open an issue on GitHub. Or find the author on X. Or send a carrier pigeon. Convex does not support those yet, but the team is probably working on it.
|
||||
|
||||
## Why Convex
|
||||
|
||||
Traditional backends make you write API routes, manage connections, handle caching, and pray nothing breaks at 3am. Convex handles all of that. You write functions. They run in the cloud. Data syncs to clients. Done.
|
||||
|
||||
The contact form example above is the entire backend. No Express. No database drivers. No WebSocket setup. Just a function that inserts a row.
|
||||
|
||||
That is why this site uses Convex.
|
||||
|
||||
@@ -6,7 +6,7 @@ order: 0
|
||||
layout: "sidebar"
|
||||
rightSidebar: true
|
||||
aiChat: true
|
||||
footer: true
|
||||
showFooter: true
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
@@ -102,23 +102,31 @@ image: "/images/og-image.png"
|
||||
Content here...
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | SEO description |
|
||||
| `date` | Yes | YYYY-MM-DD format |
|
||||
| `slug` | Yes | URL path (unique) |
|
||||
| `published` | Yes | `true` to show |
|
||||
| `tags` | Yes | Array of strings |
|
||||
| `readTime` | No | Display time estimate |
|
||||
| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax |
|
||||
| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
|
||||
| Field | Required | Description |
|
||||
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | SEO description |
|
||||
| `date` | Yes | YYYY-MM-DD format |
|
||||
| `slug` | Yes | URL path (unique) |
|
||||
| `published` | Yes | `true` to show |
|
||||
| `tags` | Yes | Array of strings |
|
||||
| `readTime` | No | Display time estimate |
|
||||
| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax |
|
||||
| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
|
||||
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
|
||||
| `showFooter` | No | Show footer on this post (overrides siteConfig default) |
|
||||
| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) |
|
||||
| `showSocialFooter` | No | Show social footer on this post (overrides siteConfig default) |
|
||||
| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
|
||||
| `blogFeatured` | No | Show as featured on blog page (first becomes hero, rest in 2-column row) |
|
||||
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
|
||||
| `contactForm` | No | Enable contact form on this post |
|
||||
|
||||
### Static pages
|
||||
|
||||
@@ -135,22 +143,28 @@ order: 1
|
||||
Content here...
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------- |
|
||||
| `title` | Yes | Nav link text |
|
||||
| `slug` | Yes | URL path |
|
||||
| `published` | Yes | `true` to show |
|
||||
| `order` | No | Nav order (lower = first) |
|
||||
| `showInNav` | No | Show in navigation menu (default: `true`) |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `image` | No | Thumbnail for featured card view |
|
||||
| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
|
||||
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
|
||||
| Field | Required | Description |
|
||||
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `title` | Yes | Nav link text |
|
||||
| `slug` | Yes | URL path |
|
||||
| `published` | Yes | `true` to show |
|
||||
| `order` | No | Nav order (lower = first) |
|
||||
| `showInNav` | No | Show in navigation menu (default: `true`) |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `image` | No | Thumbnail for featured card view |
|
||||
| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
|
||||
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
|
||||
| `showFooter` | No | Show footer on this page (overrides siteConfig default) |
|
||||
| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) |
|
||||
| `showSocialFooter` | No | Show social footer on this page (overrides siteConfig default) |
|
||||
| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
|
||||
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
|
||||
| `contactForm` | No | Enable contact form on this page |
|
||||
|
||||
**Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav.
|
||||
|
||||
@@ -803,11 +817,86 @@ The `/stats` page displays real-time analytics:
|
||||
|
||||
All stats update automatically via Convex subscriptions.
|
||||
|
||||
## Newsletter Admin
|
||||
|
||||
The Newsletter Admin page at `/newsletter-admin` provides a UI for managing subscribers and sending newsletters.
|
||||
|
||||
**Features:**
|
||||
|
||||
- View and search all subscribers (search bar in header)
|
||||
- Filter by status (all, active, unsubscribed)
|
||||
- Delete subscribers
|
||||
- Send blog posts as newsletters
|
||||
- Write and send custom emails with markdown support
|
||||
- View recent newsletter sends (last 10, includes both posts and custom emails)
|
||||
- Email statistics dashboard with:
|
||||
- Total emails sent
|
||||
- Newsletters sent count
|
||||
- Active subscribers
|
||||
- Retention rate
|
||||
- Detailed summary table
|
||||
|
||||
**Configuration:**
|
||||
|
||||
Enable in `src/config/siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
newsletterAdmin: {
|
||||
enabled: true, // Enable /newsletter-admin route
|
||||
showInNav: false, // Hide from navigation (access via direct URL)
|
||||
},
|
||||
```
|
||||
|
||||
**Environment Variables (Convex):**
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
|
||||
| `AGENTMAIL_INBOX` | Your AgentMail inbox (e.g., `inbox@agentmail.to`) |
|
||||
| `AGENTMAIL_CONTACT_EMAIL` | Optional contact form recipient (defaults to inbox) |
|
||||
|
||||
**Note:** If environment variables are not configured, users will see the error message: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX." when attempting to send newsletters or use contact forms.
|
||||
|
||||
**Sending Newsletters:**
|
||||
|
||||
The admin UI supports two sending modes:
|
||||
|
||||
1. **Send Post**: Select a published blog post to send as a newsletter
|
||||
2. **Write Email**: Compose a custom email with markdown formatting
|
||||
|
||||
Custom emails support markdown syntax:
|
||||
|
||||
- `# Heading` for headers
|
||||
- `**bold**` and `*italic*` for emphasis
|
||||
- `[link text](url)` for links
|
||||
- `- item` for bullet lists
|
||||
|
||||
**CLI Commands:**
|
||||
|
||||
You can send newsletters via command line:
|
||||
|
||||
```bash
|
||||
# Send a blog post to all subscribers
|
||||
npm run newsletter:send <post-slug>
|
||||
|
||||
# Send weekly stats summary to your inbox
|
||||
npm run newsletter:send:stats
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
npm run newsletter:send setup-guide
|
||||
```
|
||||
|
||||
The `newsletter:send` command calls the `scheduleSendPostNewsletter` mutation directly and sends emails in the background. Check the Newsletter Admin page or recent sends to see results.
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
| ------------------------------ | --------------------------- |
|
||||
| `/stats` | Real-time analytics |
|
||||
| `/newsletter-admin` | Newsletter management UI |
|
||||
| `/rss.xml` | RSS feed (descriptions) |
|
||||
| `/rss-full.xml` | RSS feed (full content) |
|
||||
| `/sitemap.xml` | XML sitemap |
|
||||
|
||||
30
content/pages/newsletter.md
Normal file
30
content/pages/newsletter.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: Newsletter
|
||||
slug: newsletter
|
||||
published: true
|
||||
order: 15
|
||||
showInNav: true
|
||||
newsletter: true
|
||||
---
|
||||
|
||||
# Newsletter
|
||||
|
||||
Stay updated with the latest posts and updates from the markdown sync framework.
|
||||
|
||||
## What you will get
|
||||
|
||||
When you subscribe, you will receive:
|
||||
|
||||
- Notifications when new blog posts are published
|
||||
- Updates about new features and improvements
|
||||
- Tips and tricks for getting the most out of markdown sync
|
||||
|
||||
## Subscribe
|
||||
|
||||
Use the form below to subscribe to our newsletter. We respect your privacy and you can unsubscribe at any time.
|
||||
|
||||
## Privacy
|
||||
|
||||
We only use your email address to send you newsletter updates. We never share your email with third parties or use it for any other purpose.
|
||||
|
||||
To unsubscribe, click the unsubscribe link at the bottom of any newsletter email.
|
||||
8
convex/_generated/api.d.ts
vendored
8
convex/_generated/api.d.ts
vendored
@@ -10,8 +10,12 @@
|
||||
|
||||
import type * as aiChatActions from "../aiChatActions.js";
|
||||
import type * as aiChats from "../aiChats.js";
|
||||
import type * as contact from "../contact.js";
|
||||
import type * as contactActions from "../contactActions.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as newsletter from "../newsletter.js";
|
||||
import type * as newsletterActions from "../newsletterActions.js";
|
||||
import type * as pages from "../pages.js";
|
||||
import type * as posts from "../posts.js";
|
||||
import type * as rss from "../rss.js";
|
||||
@@ -27,8 +31,12 @@ import type {
|
||||
declare const fullApi: ApiFromModules<{
|
||||
aiChatActions: typeof aiChatActions;
|
||||
aiChats: typeof aiChats;
|
||||
contact: typeof contact;
|
||||
contactActions: typeof contactActions;
|
||||
crons: typeof crons;
|
||||
http: typeof http;
|
||||
newsletter: typeof newsletter;
|
||||
newsletterActions: typeof newsletterActions;
|
||||
pages: typeof pages;
|
||||
posts: typeof posts;
|
||||
rss: typeof rss;
|
||||
|
||||
84
convex/contact.ts
Normal file
84
convex/contact.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { mutation, internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
// Environment variable error message for production
|
||||
const ENV_VAR_ERROR_MESSAGE = "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY, AGENTMAIL_INBOX, and AGENTMAIL_CONTACT_EMAIL.";
|
||||
|
||||
// Submit contact form message
|
||||
// Stores the message and schedules email sending via AgentMail
|
||||
export const submitContact = mutation({
|
||||
args: {
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
message: v.string(),
|
||||
source: v.string(), // "page:slug" or "post:slug"
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Validate required fields
|
||||
const name = args.name.trim();
|
||||
const email = args.email.toLowerCase().trim();
|
||||
const message = args.message.trim();
|
||||
|
||||
if (!name) {
|
||||
return { success: false, message: "Please enter your name." };
|
||||
}
|
||||
|
||||
if (!email || !email.includes("@") || !email.includes(".")) {
|
||||
return { success: false, message: "Please enter a valid email address." };
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return { success: false, message: "Please enter a message." };
|
||||
}
|
||||
|
||||
// Check environment variables before proceeding
|
||||
// Note: We can't access process.env in mutations, so we check in the action
|
||||
// But we can still store the message and let the action handle the error
|
||||
// For now, we'll store the message and let the action fail silently
|
||||
// The user will see a success message but email won't send if env vars are missing
|
||||
|
||||
// Store the message
|
||||
const messageId = await ctx.db.insert("contactMessages", {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
source: args.source,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// Schedule email sending via Node.js action
|
||||
// The action will check env vars and fail silently if not configured
|
||||
await ctx.scheduler.runAfter(0, internal.contactActions.sendContactEmail, {
|
||||
messageId,
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
source: args.source,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Thanks for your message! We'll get back to you soon.",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Mark contact message as email sent
|
||||
// Internal mutation to update emailSentAt timestamp
|
||||
export const markEmailSent = internalMutation({
|
||||
args: {
|
||||
messageId: v.id("contactMessages"),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.messageId, {
|
||||
emailSentAt: Date.now(),
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
90
convex/contactActions.ts
Normal file
90
convex/contactActions.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
"use node";
|
||||
|
||||
import { internalAction } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import { AgentMailClient } from "agentmail";
|
||||
|
||||
// Send contact form email via AgentMail SDK
|
||||
// Internal action that sends email to configured recipient
|
||||
// Uses official AgentMail SDK: https://docs.agentmail.to/quickstart
|
||||
export const sendContactEmail = internalAction({
|
||||
args: {
|
||||
messageId: v.id("contactMessages"),
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
message: v.string(),
|
||||
source: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
// Contact form sends to AGENTMAIL_CONTACT_EMAIL or falls back to inbox
|
||||
const recipientEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
|
||||
|
||||
// Silently fail if environment variables not configured
|
||||
if (!apiKey || !inbox || !recipientEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build email HTML
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">New Contact Form Submission</h2>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600; width: 100px;">From:</td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${escapeHtml(args.name)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600;">Email:</td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><a href="mailto:${escapeHtml(args.email)}">${escapeHtml(args.email)}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: 600;">Source:</td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${escapeHtml(args.source)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3 style="font-size: 16px; color: #1a1a1a; margin: 24px 0 8px 0;">Message:</h3>
|
||||
<div style="background: #f9f9f9; padding: 16px; border-radius: 6px; white-space: pre-wrap;">${escapeHtml(args.message)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Plain text version
|
||||
const text = `New Contact Form Submission\n\nFrom: ${args.name}\nEmail: ${args.email}\nSource: ${args.source}\n\nMessage:\n${args.message}`;
|
||||
|
||||
try {
|
||||
// Initialize AgentMail client with API key
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
// Send email using official SDK
|
||||
// https://docs.agentmail.to/sending-receiving-email
|
||||
await client.inboxes.messages.send(inbox, {
|
||||
to: recipientEmail,
|
||||
subject: `Contact: ${args.name} via ${args.source}`,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
|
||||
// Mark email as sent in database
|
||||
await ctx.runMutation(internal.contact.markEmailSent, {
|
||||
messageId: args.messageId,
|
||||
});
|
||||
} catch {
|
||||
// Silently fail on error
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to escape HTML entities
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -11,5 +11,29 @@ crons.interval(
|
||||
{}
|
||||
);
|
||||
|
||||
// Weekly digest: Send every Sunday at 9:00 AM UTC
|
||||
// Posts from the last 7 days are included
|
||||
// To disable, set weeklyDigest.enabled: false in siteConfig.ts
|
||||
crons.cron(
|
||||
"weekly newsletter digest",
|
||||
"0 9 * * 0", // 9:00 AM UTC on Sundays
|
||||
internal.newsletterActions.sendWeeklyDigest,
|
||||
{
|
||||
siteUrl: process.env.SITE_URL || "https://example.com",
|
||||
siteName: process.env.SITE_NAME || "Newsletter",
|
||||
}
|
||||
);
|
||||
|
||||
// Weekly stats summary: Send every Monday at 9:00 AM UTC
|
||||
// Includes subscriber count, new subscribers, newsletters sent
|
||||
crons.cron(
|
||||
"weekly stats summary",
|
||||
"0 9 * * 1", // 9:00 AM UTC on Mondays
|
||||
internal.newsletterActions.sendWeeklyStatsSummary,
|
||||
{
|
||||
siteName: process.env.SITE_NAME || "Newsletter",
|
||||
}
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ http.route({
|
||||
</url>`,
|
||||
// All posts
|
||||
...posts.map(
|
||||
(post) => ` <url>
|
||||
(post: { slug: string; date: string }) => ` <url>
|
||||
<loc>${SITE_URL}/${post.slug}</loc>
|
||||
<lastmod>${post.date}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
@@ -50,7 +50,7 @@ http.route({
|
||||
),
|
||||
// All pages
|
||||
...pages.map(
|
||||
(page) => ` <url>
|
||||
(page: { slug: string }) => ` <url>
|
||||
<loc>${SITE_URL}/${page.slug}</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
@@ -58,7 +58,7 @@ http.route({
|
||||
),
|
||||
// All tag pages
|
||||
...tags.map(
|
||||
(tagInfo) => ` <url>
|
||||
(tagInfo: { tag: string }) => ` <url>
|
||||
<loc>${SITE_URL}/tags/${encodeURIComponent(tagInfo.tag.toLowerCase())}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
@@ -92,7 +92,7 @@ http.route({
|
||||
url: SITE_URL,
|
||||
description:
|
||||
"An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs.. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.",
|
||||
posts: posts.map((post) => ({
|
||||
posts: posts.map((post: { title: string; slug: string; description: string; date: string; readTime?: string; tags: string[] }) => ({
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
description: post.description,
|
||||
@@ -193,7 +193,7 @@ http.route({
|
||||
|
||||
// Fetch full content for each post
|
||||
const fullPosts = await Promise.all(
|
||||
posts.map(async (post) => {
|
||||
posts.map(async (post: { title: string; slug: string; description: string; date: string; readTime?: string; tags: string[] }) => {
|
||||
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
|
||||
slug: post.slug,
|
||||
});
|
||||
|
||||
561
convex/newsletter.ts
Normal file
561
convex/newsletter.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
import {
|
||||
mutation,
|
||||
query,
|
||||
internalQuery,
|
||||
internalMutation,
|
||||
} from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
// Generate secure unsubscribe token
|
||||
// Uses random alphanumeric characters for URL-safe tokens
|
||||
function generateToken(): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let token = "";
|
||||
for (let i = 0; i < 32; i++) {
|
||||
token += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// Subscribe to newsletter (email only)
|
||||
// Creates new subscriber or re-subscribes existing unsubscribed user
|
||||
// Sends developer notification when a new subscriber signs up
|
||||
export const subscribe = mutation({
|
||||
args: {
|
||||
email: v.string(),
|
||||
source: v.string(), // "home", "blog-page", "post", or "post:slug-name"
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Normalize email: lowercase and trim whitespace
|
||||
const email = args.email.toLowerCase().trim();
|
||||
|
||||
// Validate email format
|
||||
if (!email || !email.includes("@") || !email.includes(".")) {
|
||||
return { success: false, message: "Please enter a valid email address." };
|
||||
}
|
||||
|
||||
// Check if already subscribed using index
|
||||
const existing = await ctx.db
|
||||
.query("newsletterSubscribers")
|
||||
.withIndex("by_email", (q) => q.eq("email", email))
|
||||
.first();
|
||||
|
||||
if (existing && existing.subscribed) {
|
||||
return { success: false, message: "You're already subscribed!" };
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const isNewSubscriber = !existing;
|
||||
|
||||
if (existing) {
|
||||
// Re-subscribe existing user with new token
|
||||
await ctx.db.patch(existing._id, {
|
||||
subscribed: true,
|
||||
subscribedAt: Date.now(),
|
||||
source: args.source,
|
||||
unsubscribeToken: token,
|
||||
unsubscribedAt: undefined,
|
||||
});
|
||||
} else {
|
||||
// Create new subscriber
|
||||
await ctx.db.insert("newsletterSubscribers", {
|
||||
email,
|
||||
subscribed: true,
|
||||
subscribedAt: Date.now(),
|
||||
source: args.source,
|
||||
unsubscribeToken: token,
|
||||
});
|
||||
}
|
||||
|
||||
// Send developer notification for new subscribers
|
||||
// Only for genuinely new subscribers, not re-subscriptions
|
||||
if (isNewSubscriber) {
|
||||
await ctx.scheduler.runAfter(0, internal.newsletterActions.notifyNewSubscriber, {
|
||||
email,
|
||||
source: args.source,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, message: "Thanks for subscribing!" };
|
||||
},
|
||||
});
|
||||
|
||||
// Unsubscribe from newsletter
|
||||
// Requires email and token for security (prevents unauthorized unsubscribes)
|
||||
export const unsubscribe = mutation({
|
||||
args: {
|
||||
email: v.string(),
|
||||
token: v.string(),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Normalize email
|
||||
const email = args.email.toLowerCase().trim();
|
||||
|
||||
// Find subscriber by email using index
|
||||
const subscriber = await ctx.db
|
||||
.query("newsletterSubscribers")
|
||||
.withIndex("by_email", (q) => q.eq("email", email))
|
||||
.first();
|
||||
|
||||
if (!subscriber) {
|
||||
return { success: false, message: "Email not found." };
|
||||
}
|
||||
|
||||
// Verify token matches
|
||||
if (subscriber.unsubscribeToken !== args.token) {
|
||||
return { success: false, message: "Invalid unsubscribe link." };
|
||||
}
|
||||
|
||||
// Check if already unsubscribed
|
||||
if (!subscriber.subscribed) {
|
||||
return { success: true, message: "You're already unsubscribed." };
|
||||
}
|
||||
|
||||
// Mark as unsubscribed
|
||||
await ctx.db.patch(subscriber._id, {
|
||||
subscribed: false,
|
||||
unsubscribedAt: Date.now(),
|
||||
});
|
||||
|
||||
return { success: true, message: "You've been unsubscribed." };
|
||||
},
|
||||
});
|
||||
|
||||
// Get subscriber count (for stats page)
|
||||
// Returns count of active subscribers
|
||||
export const getSubscriberCount = query({
|
||||
args: {},
|
||||
returns: v.number(),
|
||||
handler: async (ctx) => {
|
||||
const subscribers = await ctx.db
|
||||
.query("newsletterSubscribers")
|
||||
.withIndex("by_subscribed", (q) => q.eq("subscribed", true))
|
||||
.collect();
|
||||
return subscribers.length;
|
||||
},
|
||||
});
|
||||
|
||||
// Get active subscribers (internal, for sending newsletters)
|
||||
// Returns only email and token for each active subscriber
|
||||
export const getActiveSubscribers = internalQuery({
|
||||
args: {},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
email: v.string(),
|
||||
unsubscribeToken: v.string(),
|
||||
})
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
const subscribers = await ctx.db
|
||||
.query("newsletterSubscribers")
|
||||
.withIndex("by_subscribed", (q) => q.eq("subscribed", true))
|
||||
.collect();
|
||||
|
||||
return subscribers.map((s) => ({
|
||||
email: s.email,
|
||||
unsubscribeToken: s.unsubscribeToken,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Record that a post was sent as newsletter
|
||||
// Internal mutation called after sending newsletter
|
||||
export const recordPostSent = internalMutation({
|
||||
args: {
|
||||
postSlug: v.string(),
|
||||
sentCount: v.number(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.insert("newsletterSentPosts", {
|
||||
postSlug: args.postSlug,
|
||||
sentAt: Date.now(),
|
||||
sentCount: args.sentCount,
|
||||
type: "post",
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// Record that a custom email was sent as newsletter
|
||||
// Internal mutation called after sending custom newsletter
|
||||
export const recordCustomSent = internalMutation({
|
||||
args: {
|
||||
subject: v.string(),
|
||||
sentCount: v.number(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
// Generate a unique identifier for the custom email
|
||||
const customId = `custom-${Date.now()}`;
|
||||
await ctx.db.insert("newsletterSentPosts", {
|
||||
postSlug: customId,
|
||||
sentAt: Date.now(),
|
||||
sentCount: args.sentCount,
|
||||
type: "custom",
|
||||
subject: args.subject,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// Check if a post has already been sent as newsletter
|
||||
export const wasPostSent = internalQuery({
|
||||
args: {
|
||||
postSlug: v.string(),
|
||||
},
|
||||
returns: v.boolean(),
|
||||
handler: async (ctx, args) => {
|
||||
const sent = await ctx.db
|
||||
.query("newsletterSentPosts")
|
||||
.withIndex("by_postSlug", (q) => q.eq("postSlug", args.postSlug))
|
||||
.first();
|
||||
return sent !== null;
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Admin Queries and Mutations
|
||||
// For use in the /newsletter-admin page
|
||||
// ============================================================================
|
||||
|
||||
// Subscriber type for admin queries (excludes sensitive tokens for public queries)
|
||||
const subscriberAdminValidator = v.object({
|
||||
_id: v.id("newsletterSubscribers"),
|
||||
email: v.string(),
|
||||
subscribed: v.boolean(),
|
||||
subscribedAt: v.number(),
|
||||
unsubscribedAt: v.optional(v.number()),
|
||||
source: v.string(),
|
||||
});
|
||||
|
||||
// Get all subscribers for admin (paginated)
|
||||
// Returns subscribers without sensitive unsubscribe tokens
|
||||
export const getAllSubscribers = query({
|
||||
args: {
|
||||
limit: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
filter: v.optional(v.union(v.literal("all"), v.literal("subscribed"), v.literal("unsubscribed"))),
|
||||
search: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
subscribers: v.array(subscriberAdminValidator),
|
||||
nextCursor: v.union(v.string(), v.null()),
|
||||
totalCount: v.number(),
|
||||
subscribedCount: v.number(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const limit = args.limit ?? 50;
|
||||
const filter = args.filter ?? "all";
|
||||
const search = args.search?.toLowerCase().trim();
|
||||
|
||||
// Get all subscribers for counting
|
||||
const allSubscribers = await ctx.db
|
||||
.query("newsletterSubscribers")
|
||||
.collect();
|
||||
|
||||
// Filter by subscription status
|
||||
let filtered = allSubscribers;
|
||||
if (filter === "subscribed") {
|
||||
filtered = allSubscribers.filter((s) => s.subscribed);
|
||||
} else if (filter === "unsubscribed") {
|
||||
filtered = allSubscribers.filter((s) => !s.subscribed);
|
||||
}
|
||||
|
||||
// Search by email
|
||||
if (search) {
|
||||
filtered = filtered.filter((s) => s.email.includes(search));
|
||||
}
|
||||
|
||||
// Sort by subscribedAt descending (newest first)
|
||||
filtered.sort((a, b) => b.subscribedAt - a.subscribedAt);
|
||||
|
||||
// Pagination using cursor (subscribedAt timestamp)
|
||||
let startIndex = 0;
|
||||
if (args.cursor) {
|
||||
const cursorTime = parseInt(args.cursor, 10);
|
||||
startIndex = filtered.findIndex((s) => s.subscribedAt < cursorTime);
|
||||
if (startIndex === -1) startIndex = filtered.length;
|
||||
}
|
||||
|
||||
const pageSubscribers = filtered.slice(startIndex, startIndex + limit);
|
||||
const hasMore = startIndex + limit < filtered.length;
|
||||
|
||||
// Map to admin format (strip unsubscribeToken)
|
||||
const subscribers = pageSubscribers.map((s) => ({
|
||||
_id: s._id,
|
||||
email: s.email,
|
||||
subscribed: s.subscribed,
|
||||
subscribedAt: s.subscribedAt,
|
||||
unsubscribedAt: s.unsubscribedAt,
|
||||
source: s.source,
|
||||
}));
|
||||
|
||||
const subscribedCount = allSubscribers.filter((s) => s.subscribed).length;
|
||||
|
||||
return {
|
||||
subscribers,
|
||||
nextCursor: hasMore ? String(pageSubscribers[pageSubscribers.length - 1].subscribedAt) : null,
|
||||
totalCount: filtered.length,
|
||||
subscribedCount,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Delete subscriber (admin only)
|
||||
// Permanently removes subscriber from database
|
||||
export const deleteSubscriber = mutation({
|
||||
args: {
|
||||
subscriberId: v.id("newsletterSubscribers"),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Check if subscriber exists using direct get
|
||||
const subscriber = await ctx.db.get(args.subscriberId);
|
||||
if (!subscriber) {
|
||||
return { success: false, message: "Subscriber not found." };
|
||||
}
|
||||
|
||||
// Delete the subscriber
|
||||
await ctx.db.delete(args.subscriberId);
|
||||
|
||||
return { success: true, message: "Subscriber deleted." };
|
||||
},
|
||||
});
|
||||
|
||||
// Get newsletter stats for admin dashboard
|
||||
export const getNewsletterStats = query({
|
||||
args: {},
|
||||
returns: v.object({
|
||||
totalSubscribers: v.number(),
|
||||
activeSubscribers: v.number(),
|
||||
unsubscribedCount: v.number(),
|
||||
totalNewslettersSent: v.number(),
|
||||
totalEmailsSent: v.number(), // Sum of all sentCount
|
||||
recentNewsletters: v.array(
|
||||
v.object({
|
||||
postSlug: v.string(),
|
||||
sentAt: v.number(),
|
||||
sentCount: v.number(),
|
||||
type: v.optional(v.string()),
|
||||
subject: v.optional(v.string()),
|
||||
})
|
||||
),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
// Get all subscribers
|
||||
const subscribers = await ctx.db.query("newsletterSubscribers").collect();
|
||||
const activeSubscribers = subscribers.filter((s) => s.subscribed).length;
|
||||
const unsubscribedCount = subscribers.length - activeSubscribers;
|
||||
|
||||
// Get sent newsletters
|
||||
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
|
||||
|
||||
// Calculate total emails sent (sum of all sentCount)
|
||||
const totalEmailsSent = sentPosts.reduce((sum, p) => sum + p.sentCount, 0);
|
||||
|
||||
// Sort by sentAt descending and take last 10
|
||||
const recentNewsletters = sentPosts
|
||||
.sort((a, b) => b.sentAt - a.sentAt)
|
||||
.slice(0, 10)
|
||||
.map((p) => ({
|
||||
postSlug: p.postSlug,
|
||||
sentAt: p.sentAt,
|
||||
sentCount: p.sentCount,
|
||||
type: p.type,
|
||||
subject: p.subject,
|
||||
}));
|
||||
|
||||
return {
|
||||
totalSubscribers: subscribers.length,
|
||||
activeSubscribers,
|
||||
unsubscribedCount,
|
||||
totalNewslettersSent: sentPosts.length,
|
||||
totalEmailsSent,
|
||||
recentNewsletters,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Get list of posts available for newsletter sending
|
||||
export const getPostsForNewsletter = query({
|
||||
args: {},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
date: v.string(),
|
||||
wasSent: v.boolean(),
|
||||
})
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
// Get all published posts
|
||||
const posts = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_published", (q) => q.eq("published", true))
|
||||
.collect();
|
||||
|
||||
// Get all sent post slugs
|
||||
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
|
||||
const sentSlugs = new Set(sentPosts.map((p) => p.postSlug));
|
||||
|
||||
// Map posts with sent status, sorted by date descending
|
||||
return posts
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
.map((p) => ({
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
date: p.date,
|
||||
wasSent: sentSlugs.has(p.slug),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Internal query to get stats for weekly summary email
|
||||
export const getStatsForSummary = internalQuery({
|
||||
args: {},
|
||||
returns: v.object({
|
||||
activeSubscribers: v.number(),
|
||||
totalSubscribers: v.number(),
|
||||
newThisWeek: v.number(),
|
||||
unsubscribedCount: v.number(),
|
||||
totalNewslettersSent: v.number(),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
// Get all subscribers
|
||||
const subscribers = await ctx.db.query("newsletterSubscribers").collect();
|
||||
const activeSubscribers = subscribers.filter((s) => s.subscribed).length;
|
||||
const unsubscribedCount = subscribers.length - activeSubscribers;
|
||||
|
||||
// Calculate new subscribers this week
|
||||
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const newThisWeek = subscribers.filter(
|
||||
(s) => s.subscribedAt >= oneWeekAgo && s.subscribed
|
||||
).length;
|
||||
|
||||
// Get sent newsletters count
|
||||
const sentPosts = await ctx.db.query("newsletterSentPosts").collect();
|
||||
|
||||
return {
|
||||
activeSubscribers,
|
||||
totalSubscribers: subscribers.length,
|
||||
newThisWeek,
|
||||
unsubscribedCount,
|
||||
totalNewslettersSent: sentPosts.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Admin Mutations for Newsletter Sending
|
||||
// These schedule internal actions to send newsletters
|
||||
// ============================================================================
|
||||
|
||||
// Schedule sending a post as newsletter from admin UI
|
||||
export const scheduleSendPostNewsletter = mutation({
|
||||
args: {
|
||||
postSlug: v.string(),
|
||||
siteUrl: v.string(),
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Check if post was already sent
|
||||
const sent = await ctx.db
|
||||
.query("newsletterSentPosts")
|
||||
.withIndex("by_postSlug", (q) => q.eq("postSlug", args.postSlug))
|
||||
.first();
|
||||
|
||||
if (sent) {
|
||||
return {
|
||||
success: false,
|
||||
message: "This post has already been sent as a newsletter.",
|
||||
};
|
||||
}
|
||||
|
||||
// Schedule the action to run immediately
|
||||
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendPostNewsletter, {
|
||||
postSlug: args.postSlug,
|
||||
siteUrl: args.siteUrl,
|
||||
siteName: args.siteName,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Newsletter is being sent. Check back in a moment for results.",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Schedule sending a custom newsletter from admin UI
|
||||
export const scheduleSendCustomNewsletter = mutation({
|
||||
args: {
|
||||
subject: v.string(),
|
||||
content: v.string(),
|
||||
siteUrl: v.string(),
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Validate inputs
|
||||
if (!args.subject.trim()) {
|
||||
return { success: false, message: "Subject is required." };
|
||||
}
|
||||
if (!args.content.trim()) {
|
||||
return { success: false, message: "Content is required." };
|
||||
}
|
||||
|
||||
// Schedule the action to run immediately
|
||||
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendCustomNewsletter, {
|
||||
subject: args.subject,
|
||||
content: args.content,
|
||||
siteUrl: args.siteUrl,
|
||||
siteName: args.siteName,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Newsletter is being sent. Check back in a moment for results.",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Schedule sending weekly stats summary from CLI
|
||||
export const scheduleSendStatsSummary = mutation({
|
||||
args: {
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Schedule the action to run immediately
|
||||
await ctx.scheduler.runAfter(0, internal.newsletterActions.sendWeeklyStatsSummary, {
|
||||
siteName: args.siteName,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Stats summary is being sent. Check your inbox in a moment.",
|
||||
};
|
||||
},
|
||||
});
|
||||
552
convex/newsletterActions.ts
Normal file
552
convex/newsletterActions.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
"use node";
|
||||
|
||||
import { internalAction } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import { AgentMailClient } from "agentmail";
|
||||
|
||||
// Simple markdown to HTML converter for email content
|
||||
// Supports: headers, bold, italic, links, lists, paragraphs
|
||||
function markdownToHtml(markdown: string): string {
|
||||
let html = markdown
|
||||
// Escape HTML entities first
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
// Headers (must be at start of line)
|
||||
.replace(/^### (.+)$/gm, '<h3 style="font-size: 18px; color: #1a1a1a; margin: 16px 0 8px;">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 style="font-size: 20px; color: #1a1a1a; margin: 20px 0 10px;">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 style="font-size: 24px; color: #1a1a1a; margin: 24px 0 12px;">$1</h1>')
|
||||
// Bold and italic
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||
// Links
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color: #1a73e8; text-decoration: none;">$1</a>')
|
||||
// Unordered lists
|
||||
.replace(/^- (.+)$/gm, '<li style="margin: 4px 0;">$1</li>')
|
||||
.replace(/(<li[^>]*>.*<\/li>\n?)+/g, '<ul style="padding-left: 20px; margin: 12px 0;">$&</ul>')
|
||||
// Line breaks (double newline = paragraph)
|
||||
.replace(/\n\n/g, '</p><p style="margin: 12px 0; line-height: 1.6;">')
|
||||
// Single line breaks
|
||||
.replace(/\n/g, '<br />');
|
||||
|
||||
// Wrap in paragraph if not starting with a block element
|
||||
if (!html.startsWith('<h') && !html.startsWith('<ul')) {
|
||||
html = `<p style="margin: 12px 0; line-height: 1.6;">${html}</p>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Convert markdown to plain text for email fallback
|
||||
function markdownToText(markdown: string): string {
|
||||
return markdown
|
||||
// Remove markdown formatting
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/__(.+?)__/g, '$1')
|
||||
.replace(/_(.+?)_/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
|
||||
.replace(/^#{1,3} /gm, '')
|
||||
.replace(/^- /gm, '* ');
|
||||
}
|
||||
|
||||
// Environment variable error message for production
|
||||
const ENV_VAR_ERROR_MESSAGE = "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX.";
|
||||
|
||||
// Send newsletter for a specific post to all active subscribers
|
||||
// Uses AgentMail SDK to send emails
|
||||
// https://docs.agentmail.to/sending-receiving-email
|
||||
export const sendPostNewsletter = internalAction({
|
||||
args: {
|
||||
postSlug: v.string(),
|
||||
siteUrl: v.string(),
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
sentCount: v.number(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Check if post was already sent
|
||||
const alreadySent: boolean = await ctx.runQuery(
|
||||
internal.newsletter.wasPostSent,
|
||||
{ postSlug: args.postSlug }
|
||||
);
|
||||
if (alreadySent) {
|
||||
return {
|
||||
success: false,
|
||||
sentCount: 0,
|
||||
message: "This post has already been sent as a newsletter.",
|
||||
};
|
||||
}
|
||||
|
||||
// Get subscribers
|
||||
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
|
||||
await ctx.runQuery(internal.newsletter.getActiveSubscribers);
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
return { success: false, sentCount: 0, message: "No subscribers." };
|
||||
}
|
||||
|
||||
// Get post details
|
||||
const post = await ctx.runQuery(internal.posts.getPostBySlugInternal, {
|
||||
slug: args.postSlug,
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return { success: false, sentCount: 0, message: "Post not found." };
|
||||
}
|
||||
|
||||
// Get API key and inbox from environment
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
|
||||
if (!apiKey || !inbox) {
|
||||
return {
|
||||
success: false,
|
||||
sentCount: 0,
|
||||
message: ENV_VAR_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
const siteName = args.siteName || "Newsletter";
|
||||
let sentCount = 0;
|
||||
const errors: Array<string> = [];
|
||||
|
||||
// Initialize AgentMail client once
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
// Send to each subscriber
|
||||
for (const subscriber of subscribers) {
|
||||
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
|
||||
const postUrl = `${args.siteUrl}/${post.slug}`;
|
||||
|
||||
// Build email HTML
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="font-size: 24px; color: #1a1a1a; margin-bottom: 16px;">${escapeHtml(post.title)}</h1>
|
||||
<p style="font-size: 16px; color: #444; line-height: 1.6; margin-bottom: 24px;">${escapeHtml(post.description)}</p>
|
||||
${post.excerpt ? `<p style="font-size: 14px; color: #666; line-height: 1.5; margin-bottom: 24px;">${escapeHtml(post.excerpt)}</p>` : ""}
|
||||
<p style="margin-bottom: 32px;">
|
||||
<a href="${postUrl}" style="display: inline-block; padding: 12px 24px; background: #1a1a1a; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 500;">Read more</a>
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="font-size: 12px; color: #888;">
|
||||
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
|
||||
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Plain text version
|
||||
const text = `${post.title}\n\n${post.description}\n\nRead more: ${postUrl}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
|
||||
|
||||
// Send email using AgentMail SDK
|
||||
try {
|
||||
await client.inboxes.messages.send(inbox, {
|
||||
to: subscriber.email,
|
||||
subject: `New: ${post.title}`,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
sentCount++;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
errors.push(`${subscriber.email}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Record sent if at least one email was sent
|
||||
if (sentCount > 0) {
|
||||
await ctx.runMutation(internal.newsletter.recordPostSent, {
|
||||
postSlug: args.postSlug,
|
||||
sentCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Build result message
|
||||
let resultMessage: string = `Sent to ${sentCount} of ${subscribers.length} subscribers.`;
|
||||
if (errors.length > 0) {
|
||||
resultMessage += ` ${errors.length} failed.`;
|
||||
}
|
||||
|
||||
return { success: sentCount > 0, sentCount, message: resultMessage };
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to escape HTML entities
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Send weekly digest email to all active subscribers
|
||||
// Includes all posts published in the last 7 days
|
||||
export const sendWeeklyDigest = internalAction({
|
||||
args: {
|
||||
siteUrl: v.string(),
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
sentCount: v.number(),
|
||||
postCount: v.number(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Get subscribers
|
||||
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
|
||||
await ctx.runQuery(internal.newsletter.getActiveSubscribers);
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
sentCount: 0,
|
||||
postCount: 0,
|
||||
message: "No subscribers.",
|
||||
};
|
||||
}
|
||||
|
||||
// Get posts from last 7 days
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
const cutoffDate = sevenDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
const recentPosts: Array<{
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
excerpt?: string;
|
||||
}> = await ctx.runQuery(internal.posts.getRecentPostsInternal, {
|
||||
since: cutoffDate,
|
||||
});
|
||||
|
||||
if (recentPosts.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
sentCount: 0,
|
||||
postCount: 0,
|
||||
message: "No new posts in the last 7 days.",
|
||||
};
|
||||
}
|
||||
|
||||
// Get API key and inbox from environment
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
|
||||
if (!apiKey || !inbox) {
|
||||
return {
|
||||
success: false,
|
||||
sentCount: 0,
|
||||
postCount: 0,
|
||||
message: ENV_VAR_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
const siteName = args.siteName || "Newsletter";
|
||||
let sentCount = 0;
|
||||
const errors: Array<string> = [];
|
||||
|
||||
// Initialize AgentMail client
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
// Build email content
|
||||
const postsHtml = recentPosts
|
||||
.map(
|
||||
(post) => `
|
||||
<div style="margin-bottom: 24px; padding: 16px; background: #f9f9f9; border-radius: 8px;">
|
||||
<h3 style="font-size: 18px; color: #1a1a1a; margin: 0 0 8px 0;">
|
||||
<a href="${args.siteUrl}/${post.slug}" style="color: #1a1a1a; text-decoration: none;">${escapeHtml(post.title)}</a>
|
||||
</h3>
|
||||
<p style="font-size: 14px; color: #666; margin: 0 0 8px 0;">${escapeHtml(post.description)}</p>
|
||||
<p style="font-size: 12px; color: #888; margin: 0;">${post.date}</p>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const postsText = recentPosts
|
||||
.map(
|
||||
(post) =>
|
||||
`${post.title}\n${post.description}\n${args.siteUrl}/${post.slug}\n${post.date}`
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
// Send to each subscriber
|
||||
for (const subscriber of subscribers) {
|
||||
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
|
||||
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="font-size: 24px; color: #1a1a1a; margin-bottom: 8px;">Weekly Digest</h1>
|
||||
<p style="font-size: 14px; color: #666; margin-bottom: 24px;">${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""} from ${escapeHtml(siteName)}</p>
|
||||
${postsHtml}
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="font-size: 12px; color: #888;">
|
||||
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
|
||||
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const text = `Weekly Digest - ${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""}\n\n${postsText}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
|
||||
|
||||
try {
|
||||
await client.inboxes.messages.send(inbox, {
|
||||
to: subscriber.email,
|
||||
subject: `Weekly Digest: ${recentPosts.length} new post${recentPosts.length > 1 ? "s" : ""}`,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
sentCount++;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
errors.push(`${subscriber.email}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
let resultMessage: string = `Sent ${recentPosts.length} post${recentPosts.length > 1 ? "s" : ""} to ${sentCount} of ${subscribers.length} subscribers.`;
|
||||
if (errors.length > 0) {
|
||||
resultMessage += ` ${errors.length} failed.`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: sentCount > 0,
|
||||
sentCount,
|
||||
postCount: recentPosts.length,
|
||||
message: resultMessage,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Send new subscriber notification to developer
|
||||
// Called when a new subscriber signs up
|
||||
export const notifyNewSubscriber = internalAction({
|
||||
args: {
|
||||
email: v.string(),
|
||||
source: v.string(),
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (_ctx, args) => {
|
||||
// Get API key and inbox from environment
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
const contactEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
|
||||
|
||||
if (!apiKey || !contactEmail) {
|
||||
return {
|
||||
success: false,
|
||||
message: ENV_VAR_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
const siteName = args.siteName || "Your Site";
|
||||
const timestamp = new Date().toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
try {
|
||||
await client.inboxes.messages.send(inbox!, {
|
||||
to: contactEmail,
|
||||
subject: `New subscriber: ${args.email}`,
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">New Newsletter Subscriber</h2>
|
||||
<p style="font-size: 14px; color: #444; line-height: 1.6;">
|
||||
<strong>Email:</strong> ${escapeHtml(args.email)}<br />
|
||||
<strong>Source:</strong> ${escapeHtml(args.source)}<br />
|
||||
<strong>Time:</strong> ${timestamp}
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||
<p style="font-size: 12px; color: #888;">
|
||||
This is an automated notification from ${escapeHtml(siteName)}.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
text: `New Newsletter Subscriber\n\nEmail: ${args.email}\nSource: ${args.source}\nTime: ${timestamp}`,
|
||||
});
|
||||
|
||||
return { success: true, message: "Notification sent." };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Send weekly stats summary to developer
|
||||
// Includes subscriber count, new subscribers, newsletters sent
|
||||
export const sendWeeklyStatsSummary = internalAction({
|
||||
args: {
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Get API key and inbox from environment
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
const contactEmail = process.env.AGENTMAIL_CONTACT_EMAIL || inbox;
|
||||
|
||||
if (!apiKey || !contactEmail) {
|
||||
return { success: false, message: ENV_VAR_ERROR_MESSAGE };
|
||||
}
|
||||
|
||||
const siteName = args.siteName || "Your Site";
|
||||
|
||||
// Get stats from database
|
||||
const stats = await ctx.runQuery(internal.newsletter.getStatsForSummary);
|
||||
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
try {
|
||||
await client.inboxes.messages.send(inbox!, {
|
||||
to: contactEmail,
|
||||
subject: `Weekly Stats: ${stats.activeSubscribers} subscribers`,
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="font-size: 20px; color: #1a1a1a; margin-bottom: 16px;">Weekly Newsletter Stats</h2>
|
||||
<div style="background: #f9f9f9; padding: 20px; border-radius: 8px; margin-bottom: 24px;">
|
||||
<p style="font-size: 14px; color: #444; line-height: 1.8; margin: 0;">
|
||||
<strong>Active Subscribers:</strong> ${stats.activeSubscribers}<br />
|
||||
<strong>Total Subscribers:</strong> ${stats.totalSubscribers}<br />
|
||||
<strong>New This Week:</strong> ${stats.newThisWeek}<br />
|
||||
<strong>Unsubscribed:</strong> ${stats.unsubscribedCount}<br />
|
||||
<strong>Newsletters Sent:</strong> ${stats.totalNewslettersSent}
|
||||
</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||
<p style="font-size: 12px; color: #888;">
|
||||
This is an automated weekly summary from ${escapeHtml(siteName)}.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Weekly Newsletter Stats\n\nActive Subscribers: ${stats.activeSubscribers}\nTotal Subscribers: ${stats.totalSubscribers}\nNew This Week: ${stats.newThisWeek}\nUnsubscribed: ${stats.unsubscribedCount}\nNewsletters Sent: ${stats.totalNewslettersSent}`,
|
||||
});
|
||||
|
||||
return { success: true, message: "Stats summary sent." };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Send custom newsletter email to all active subscribers
|
||||
// Supports markdown content that gets converted to HTML
|
||||
export const sendCustomNewsletter = internalAction({
|
||||
args: {
|
||||
subject: v.string(),
|
||||
content: v.string(), // Markdown content
|
||||
siteUrl: v.string(),
|
||||
siteName: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
success: v.boolean(),
|
||||
sentCount: v.number(),
|
||||
message: v.string(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
// Get subscribers
|
||||
const subscribers: Array<{ email: string; unsubscribeToken: string }> =
|
||||
await ctx.runQuery(internal.newsletter.getActiveSubscribers);
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
return { success: false, sentCount: 0, message: "No subscribers." };
|
||||
}
|
||||
|
||||
// Get API key and inbox from environment
|
||||
const apiKey = process.env.AGENTMAIL_API_KEY;
|
||||
const inbox = process.env.AGENTMAIL_INBOX;
|
||||
|
||||
if (!apiKey || !inbox) {
|
||||
return {
|
||||
success: false,
|
||||
sentCount: 0,
|
||||
message: ENV_VAR_ERROR_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
const siteName = args.siteName || "Newsletter";
|
||||
let sentCount = 0;
|
||||
const errors: Array<string> = [];
|
||||
|
||||
// Convert markdown to HTML and plain text
|
||||
const contentHtml = markdownToHtml(args.content);
|
||||
const contentText = markdownToText(args.content);
|
||||
|
||||
// Initialize AgentMail client
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
|
||||
// Send to each subscriber
|
||||
for (const subscriber of subscribers) {
|
||||
const unsubscribeUrl = `${args.siteUrl}/unsubscribe?email=${encodeURIComponent(subscriber.email)}&token=${subscriber.unsubscribeToken}`;
|
||||
|
||||
// Build email HTML with styling
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; color: #333;">
|
||||
${contentHtml}
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="font-size: 12px; color: #888;">
|
||||
You received this email because you subscribed to ${escapeHtml(siteName)}.<br />
|
||||
<a href="${unsubscribeUrl}" style="color: #888;">Unsubscribe</a>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const text = `${contentText}\n\n---\nUnsubscribe: ${unsubscribeUrl}`;
|
||||
|
||||
try {
|
||||
await client.inboxes.messages.send(inbox, {
|
||||
to: subscriber.email,
|
||||
subject: args.subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
sentCount++;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
errors.push(`${subscriber.email}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Record custom email send if at least one email was sent
|
||||
if (sentCount > 0) {
|
||||
await ctx.runMutation(internal.newsletter.recordCustomSent, {
|
||||
subject: args.subject,
|
||||
sentCount,
|
||||
});
|
||||
}
|
||||
|
||||
let resultMessage: string = `Sent to ${sentCount} of ${subscribers.length} subscribers.`;
|
||||
if (errors.length > 0) {
|
||||
resultMessage += ` ${errors.length} failed.`;
|
||||
}
|
||||
|
||||
return { success: sentCount > 0, sentCount, message: resultMessage };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -128,7 +128,10 @@ export const getPageBySlug = query({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
showSocialFooter: v.optional(v.boolean()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
contactForm: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
@@ -161,7 +164,10 @@ export const getPageBySlug = query({
|
||||
rightSidebar: page.rightSidebar,
|
||||
showFooter: page.showFooter,
|
||||
footer: page.footer,
|
||||
showSocialFooter: page.showSocialFooter,
|
||||
aiChat: page.aiChat,
|
||||
contactForm: page.contactForm,
|
||||
newsletter: page.newsletter,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -188,7 +194,10 @@ export const syncPagesPublic = mutation({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
showSocialFooter: v.optional(v.boolean()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
contactForm: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -232,7 +241,10 @@ export const syncPagesPublic = mutation({
|
||||
rightSidebar: page.rightSidebar,
|
||||
showFooter: page.showFooter,
|
||||
footer: page.footer,
|
||||
showSocialFooter: page.showSocialFooter,
|
||||
aiChat: page.aiChat,
|
||||
contactForm: page.contactForm,
|
||||
newsletter: page.newsletter,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { query, mutation, internalMutation } from "./_generated/server";
|
||||
import { query, mutation, internalMutation, internalQuery } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
// Get all published posts, sorted by date descending
|
||||
@@ -179,7 +179,10 @@ export const getPostBySlug = query({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
showSocialFooter: v.optional(v.boolean()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
contactForm: v.optional(v.boolean()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
@@ -215,11 +218,87 @@ export const getPostBySlug = query({
|
||||
rightSidebar: post.rightSidebar,
|
||||
showFooter: post.showFooter,
|
||||
footer: post.footer,
|
||||
showSocialFooter: post.showSocialFooter,
|
||||
aiChat: post.aiChat,
|
||||
newsletter: post.newsletter,
|
||||
contactForm: post.contactForm,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Internal query to get post by slug (for newsletter sending)
|
||||
// Returns post details needed for newsletter content
|
||||
export const getPostBySlugInternal = internalQuery({
|
||||
args: {
|
||||
slug: v.string(),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
description: v.string(),
|
||||
content: v.string(),
|
||||
excerpt: v.optional(v.string()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const post = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
||||
.first();
|
||||
|
||||
if (!post || !post.published) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
content: post.content,
|
||||
excerpt: post.excerpt,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Internal query to get recent posts (for weekly digest)
|
||||
// Returns published posts with date >= since parameter
|
||||
export const getRecentPostsInternal = internalQuery({
|
||||
args: {
|
||||
since: v.string(), // Date string in YYYY-MM-DD format
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
slug: v.string(),
|
||||
title: v.string(),
|
||||
description: v.string(),
|
||||
date: v.string(),
|
||||
excerpt: v.optional(v.string()),
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const posts = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_published", (q) => q.eq("published", true))
|
||||
.collect();
|
||||
|
||||
// Filter posts by date and sort descending
|
||||
const recentPosts = posts
|
||||
.filter((post) => post.date >= args.since)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
.map((post) => ({
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
date: post.date,
|
||||
excerpt: post.excerpt,
|
||||
}));
|
||||
|
||||
return recentPosts;
|
||||
},
|
||||
});
|
||||
|
||||
// Internal mutation for syncing posts from markdown files
|
||||
export const syncPosts = internalMutation({
|
||||
args: {
|
||||
@@ -244,8 +323,11 @@ export const syncPosts = internalMutation({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
showSocialFooter: v.optional(v.boolean()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
blogFeatured: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
contactForm: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -291,8 +373,11 @@ export const syncPosts = internalMutation({
|
||||
rightSidebar: post.rightSidebar,
|
||||
showFooter: post.showFooter,
|
||||
footer: post.footer,
|
||||
showSocialFooter: post.showSocialFooter,
|
||||
aiChat: post.aiChat,
|
||||
blogFeatured: post.blogFeatured,
|
||||
newsletter: post.newsletter,
|
||||
contactForm: post.contactForm,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
@@ -342,8 +427,11 @@ export const syncPostsPublic = mutation({
|
||||
rightSidebar: v.optional(v.boolean()),
|
||||
showFooter: v.optional(v.boolean()),
|
||||
footer: v.optional(v.string()),
|
||||
showSocialFooter: v.optional(v.boolean()),
|
||||
aiChat: v.optional(v.boolean()),
|
||||
blogFeatured: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
contactForm: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -389,8 +477,11 @@ export const syncPostsPublic = mutation({
|
||||
rightSidebar: post.rightSidebar,
|
||||
showFooter: post.showFooter,
|
||||
footer: post.footer,
|
||||
showSocialFooter: post.showSocialFooter,
|
||||
aiChat: post.aiChat,
|
||||
blogFeatured: post.blogFeatured,
|
||||
newsletter: post.newsletter,
|
||||
contactForm: post.contactForm,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
|
||||
@@ -106,7 +106,7 @@ export const rssFeed = httpAction(async (ctx) => {
|
||||
const posts = await ctx.runQuery(api.posts.getAllPosts);
|
||||
|
||||
const xml = generateRssXml(
|
||||
posts.map((post) => ({
|
||||
posts.map((post: { title: string; description: string; slug: string; date: string }) => ({
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
slug: post.slug,
|
||||
@@ -128,7 +128,7 @@ export const rssFullFeed = httpAction(async (ctx) => {
|
||||
|
||||
// Fetch full content for each post
|
||||
const fullPosts = await Promise.all(
|
||||
posts.map(async (post) => {
|
||||
posts.map(async (post: { title: string; description: string; slug: string; date: string; readTime?: string; tags: string[] }) => {
|
||||
const fullPost = await ctx.runQuery(api.posts.getPostBySlug, {
|
||||
slug: post.slug,
|
||||
});
|
||||
|
||||
@@ -23,8 +23,11 @@ export default defineSchema({
|
||||
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
|
||||
showFooter: v.optional(v.boolean()), // Show footer on this post (overrides siteConfig default)
|
||||
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
|
||||
showSocialFooter: v.optional(v.boolean()), // Show social footer on this post (overrides siteConfig default)
|
||||
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
|
||||
blogFeatured: v.optional(v.boolean()), // Show as hero featured post on /blog page
|
||||
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
|
||||
contactForm: v.optional(v.boolean()), // Enable contact form on this post
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
@@ -60,7 +63,10 @@ export default defineSchema({
|
||||
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
|
||||
showFooter: v.optional(v.boolean()), // Show footer on this page (overrides siteConfig default)
|
||||
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
|
||||
showSocialFooter: v.optional(v.boolean()), // Show social footer on this page (overrides siteConfig default)
|
||||
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
|
||||
contactForm: v.optional(v.boolean()), // Enable contact form on this page
|
||||
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
|
||||
lastSyncedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
@@ -139,4 +145,40 @@ export default defineSchema({
|
||||
})
|
||||
.index("by_session_and_context", ["sessionId", "contextId"])
|
||||
.index("by_session", ["sessionId"]),
|
||||
|
||||
// Newsletter subscribers table
|
||||
// Stores email subscriptions with unsubscribe tokens
|
||||
newsletterSubscribers: defineTable({
|
||||
email: v.string(), // Subscriber email address (lowercase, trimmed)
|
||||
subscribed: v.boolean(), // Current subscription status
|
||||
subscribedAt: v.number(), // Timestamp when subscribed
|
||||
unsubscribedAt: v.optional(v.number()), // Timestamp when unsubscribed (if applicable)
|
||||
source: v.string(), // Where they signed up: "home", "blog-page", "post", or "post:slug-name"
|
||||
unsubscribeToken: v.string(), // Secure token for unsubscribe links
|
||||
})
|
||||
.index("by_email", ["email"])
|
||||
.index("by_subscribed", ["subscribed"]),
|
||||
|
||||
// Newsletter sent tracking (posts and custom emails)
|
||||
// Tracks what has been sent to prevent duplicate newsletters
|
||||
newsletterSentPosts: defineTable({
|
||||
postSlug: v.string(), // Slug of the post or custom email identifier
|
||||
sentAt: v.number(), // Timestamp when the newsletter was sent
|
||||
sentCount: v.number(), // Number of subscribers it was sent to
|
||||
type: v.optional(v.string()), // "post" or "custom" (default "post" for backwards compat)
|
||||
subject: v.optional(v.string()), // Subject line for custom emails
|
||||
})
|
||||
.index("by_postSlug", ["postSlug"])
|
||||
.index("by_sentAt", ["sentAt"]),
|
||||
|
||||
// Contact form messages
|
||||
// Stores messages submitted via contact forms on posts/pages
|
||||
contactMessages: defineTable({
|
||||
name: v.string(), // Sender's name
|
||||
email: v.string(), // Sender's email address
|
||||
message: v.string(), // Message content
|
||||
source: v.string(), // Where submitted from: "page:slug" or "post:slug"
|
||||
createdAt: v.number(), // Timestamp when submitted
|
||||
emailSentAt: v.optional(v.number()), // Timestamp when email was sent (if applicable)
|
||||
}).index("by_createdAt", ["createdAt"]),
|
||||
});
|
||||
|
||||
26
files.md
26
files.md
@@ -33,7 +33,7 @@ A brief description of each file in the codebase.
|
||||
|
||||
| File | Description |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration, homepage configuration, AI chat configuration) |
|
||||
| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration, social footer configuration, homepage configuration, AI chat configuration, newsletter configuration, contact form configuration) |
|
||||
|
||||
### Pages (`src/pages/`)
|
||||
|
||||
@@ -45,6 +45,7 @@ A brief description of each file in the codebase.
|
||||
| `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars |
|
||||
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
|
||||
| `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans/monospace), localStorage persistence, and optional AI Agent mode (toggleable via siteConfig.aiChat.enabledOnWritePage). When enabled, Agent replaces the textarea with AIChatView component. Includes scroll prevention when switching to Agent mode to prevent page jump. Title changes to "Agent" when in AI chat mode. |
|
||||
| `NewsletterAdmin.tsx` | Three-column newsletter admin page for managing subscribers and sending newsletters. Left sidebar with navigation and stats, main area with searchable subscriber list, right sidebar with send newsletter panel and recent sends. Access at /newsletter-admin, configurable via siteConfig.newsletterAdmin. |
|
||||
|
||||
### Components (`src/components/`)
|
||||
|
||||
@@ -67,6 +68,9 @@ A brief description of each file in the codebase.
|
||||
| `PageSidebar.tsx` | Collapsible table of contents sidebar for pages/posts with sidebar layout, extracts headings (H1-H6), active heading highlighting, smooth scroll navigation, localStorage persistence for expanded/collapsed state |
|
||||
| `RightSidebar.tsx` | Right sidebar component that displays CopyPageDropdown or AI chat on posts/pages at 1135px+ viewport width, controlled by siteConfig.rightSidebar.enabled and frontmatter rightSidebar/aiChat fields |
|
||||
| `AIChatView.tsx` | AI chat interface component (Agent) using Anthropic Claude API. Supports per-page chat history, page content context, markdown rendering, and copy functionality. Used in Write page (replaces textarea when enabled) and optionally in RightSidebar. Requires ANTHROPIC_API_KEY environment variable in Convex. System prompt configurable via CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES, or CLAUDE_SYSTEM_PROMPT environment variables. Includes error handling for missing API keys. |
|
||||
| `NewsletterSignup.tsx` | Newsletter signup form component for email-only subscriptions. Displays configurable title/description, validates email, and submits to Convex. Shows on home, blog page, and posts based on siteConfig.newsletter settings. Supports frontmatter override via newsletter: true/false. |
|
||||
| `ContactForm.tsx` | Contact form component with name, email, and message fields. Displays when contactForm: true in frontmatter. Submits to Convex which sends email via AgentMail to configured recipient. |
|
||||
| `SocialFooter.tsx` | Social footer component with social icons on left (GitHub, Twitter/X, LinkedIn, etc.) and copyright on right. Configurable via siteConfig.socialFooter. Shows below main footer on homepage, blog posts, and pages. Supports frontmatter override via showSocialFooter: true/false. |
|
||||
|
||||
### Context (`src/context/`)
|
||||
|
||||
@@ -98,16 +102,19 @@ A brief description of each file in the codebase.
|
||||
|
||||
| File | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats) with indexes for tag and AI queries |
|
||||
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats, newsletterSubscribers, newsletterSentPosts, contactMessages) with indexes for tag and AI queries. Posts and pages include showSocialFooter field for frontmatter control. |
|
||||
| `posts.ts` | Queries and mutations for blog posts, view counts, getAllTags, getPostsByTag, and getRelatedPosts |
|
||||
| `pages.ts` | Queries and mutations for static pages |
|
||||
| `search.ts` | Full text search queries across posts and pages |
|
||||
| `stats.ts` | Real-time stats with aggregate component for O(log n) counts, page view recording, session heartbeat |
|
||||
| `crons.ts` | Cron job for stale session cleanup |
|
||||
| `crons.ts` | Cron jobs for stale session cleanup, weekly newsletter digest (Sundays 9am UTC), and weekly stats summary (Mondays 9am UTC) |
|
||||
| `http.ts` | HTTP endpoints: sitemap, API (update SITE_URL/SITE_NAME when forking, uses www.markdown.fast) |
|
||||
| `rss.ts` | RSS feed generation (update SITE_URL/SITE_TITLE when forking, uses www.markdown.fast) |
|
||||
| `aiChats.ts` | Queries and mutations for AI chat history (per-session, per-context storage). Handles anonymous session IDs, per-page chat contexts, and message history management. Supports page content as context for AI responses. |
|
||||
| `aiChatActions.ts` | Anthropic Claude API integration action for AI chat responses. Requires ANTHROPIC_API_KEY environment variable in Convex. Uses claude-sonnet-3-5-20240620 model. System prompt configurable via environment variables (CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES, or CLAUDE_SYSTEM_PROMPT). Includes error handling for missing API keys with user-friendly error messages. Supports page content context and chat history (last 20 messages). |
|
||||
| `newsletter.ts` | Newsletter mutations and queries: subscribe, unsubscribe, getSubscriberCount, getActiveSubscribers, getAllSubscribers (admin), deleteSubscriber (admin), getNewsletterStats, getPostsForNewsletter, wasPostSent, recordPostSent. |
|
||||
| `newsletterActions.ts` | Newsletter actions (Node.js runtime): sendPostNewsletter, sendWeeklyDigest, notifyNewSubscriber, sendWeeklyStatsSummary. Uses AgentMail SDK for email delivery. |
|
||||
| `contact.ts` | Contact form mutations and actions: submitContact, sendContactEmail (AgentMail API), markEmailSent. |
|
||||
| `convex.config.ts` | Convex app configuration with aggregate component registrations (pageViewsByPath, totalPageViews, uniqueVisitors) |
|
||||
| `tsconfig.json` | Convex TypeScript configuration |
|
||||
|
||||
@@ -151,7 +158,11 @@ Markdown files with frontmatter for blog posts. Each file becomes a blog post.
|
||||
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
|
||||
| `showFooter` | Show footer on this post (optional, overrides siteConfig default) |
|
||||
| `footer` | Footer markdown content (optional, overrides siteConfig.defaultContent) |
|
||||
| `aiChat` | Enable AI Agent chat in right sidebar (optional, requires rightSidebar: true and siteConfig.aiChat.enabledOnContent: true) |
|
||||
| `showSocialFooter` | Show social footer on this post (optional, overrides siteConfig default) |
|
||||
| `aiChat` | Enable AI Agent chat in right sidebar (optional). Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
|
||||
| `blogFeatured` | Show as featured on blog page (optional, first becomes hero, rest in 2-column row) |
|
||||
| `newsletter` | Override newsletter signup display (optional, true/false) |
|
||||
| `contactForm` | Enable contact form on this post (optional) |
|
||||
|
||||
## Static Pages (`content/pages/`)
|
||||
|
||||
@@ -174,7 +185,10 @@ Markdown files for static pages like About, Projects, Contact, Changelog.
|
||||
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
|
||||
| `showFooter` | Show footer on this page (optional, overrides siteConfig default) |
|
||||
| `footer` | Footer markdown content (optional, overrides siteConfig.defaultContent) |
|
||||
| `aiChat` | Enable AI Agent chat in right sidebar (optional, requires rightSidebar: true and siteConfig.aiChat.enabledOnContent: true) |
|
||||
| `showSocialFooter` | Show social footer on this page (optional, overrides siteConfig default) |
|
||||
| `aiChat` | Enable AI Agent chat in right sidebar (optional). Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
|
||||
| `newsletter` | Override newsletter signup display (optional, true/false) |
|
||||
| `contactForm` | Enable contact form on this page (optional) |
|
||||
|
||||
## Scripts (`scripts/`)
|
||||
|
||||
@@ -184,6 +198,8 @@ Markdown files for static pages like About, Projects, Contact, Changelog.
|
||||
| `sync-discovery-files.ts` | Updates AGENTS.md and llms.txt with current app data |
|
||||
| `import-url.ts` | Imports external URLs as markdown posts (Firecrawl) |
|
||||
| `configure-fork.ts` | Automated fork configuration (reads fork-config.json) |
|
||||
| `send-newsletter.ts` | CLI tool for sending newsletter posts (npm run newsletter:send <slug>) |
|
||||
| `send-newsletter-stats.ts` | CLI tool for sending weekly stats summary (npm run newsletter:send:stats) |
|
||||
|
||||
### Sync Commands
|
||||
|
||||
|
||||
@@ -55,6 +55,32 @@
|
||||
"type": "default",
|
||||
"slug": null,
|
||||
"originalHomeRoute": "/home"
|
||||
},
|
||||
"newsletter": {
|
||||
"enabled": false,
|
||||
"agentmail": {
|
||||
"inbox": "newsletter@mail.agentmail.to"
|
||||
},
|
||||
"signup": {
|
||||
"home": {
|
||||
"enabled": false,
|
||||
"position": "above-footer",
|
||||
"title": "Stay Updated",
|
||||
"description": "Get new posts delivered to your inbox."
|
||||
},
|
||||
"blogPage": {
|
||||
"enabled": false,
|
||||
"position": "above-footer",
|
||||
"title": "Subscribe",
|
||||
"description": "Get notified when new posts are published."
|
||||
},
|
||||
"posts": {
|
||||
"enabled": false,
|
||||
"position": "below-content",
|
||||
"title": "Enjoyed this post?",
|
||||
"description": "Subscribe for more updates."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@mendable/firecrawl-js": "^1.21.1",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"agentmail": "^0.1.15",
|
||||
"convex": "^1.17.4",
|
||||
"date-fns": "^3.3.1",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -1785,6 +1786,17 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agentmail": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/agentmail/-/agentmail-0.1.15.tgz",
|
||||
"integrity": "sha512-BXnTcAFbB30RzLxg+gPs2weHGI1e6pndHIieNSd1sXlPX0w52qiz2ZXQebadbBzKet+/Ix46U0WG8nXPZIymMQ==",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -6970,7 +6982,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"sync:all:prod": "npm run sync:prod && npm run sync:discovery:prod",
|
||||
"import": "npx tsx scripts/import-url.ts",
|
||||
"configure": "npx tsx scripts/configure-fork.ts",
|
||||
"newsletter:send": "npx tsx scripts/send-newsletter.ts",
|
||||
"newsletter:send:stats": "npx tsx scripts/send-newsletter-stats.ts",
|
||||
"deploy": "npm run sync && npm run build",
|
||||
"deploy:prod": "npx convex deploy && npm run sync:prod"
|
||||
},
|
||||
@@ -27,6 +29,7 @@
|
||||
"@mendable/firecrawl-js": "^1.21.1",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"agentmail": "^0.1.15",
|
||||
"convex": "^1.17.4",
|
||||
"date-fns": "^3.3.1",
|
||||
"gray-matter": "^4.0.3",
|
||||
|
||||
96
prds/agentmail-contact-form-fix.md
Normal file
96
prds/agentmail-contact-form-fix.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# AgentMail Contact Form Fix
|
||||
|
||||
## Problem
|
||||
|
||||
Contact form submissions were failing with 404 errors when trying to send emails via AgentMail. The error message was:
|
||||
|
||||
```
|
||||
Failed to send contact email (404): {"message":"Not Found"}
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
The code was attempting to use a REST API endpoint that doesn't exist:
|
||||
|
||||
```
|
||||
POST https://api.agentmail.to/v1/inboxes/{inbox_id}/messages
|
||||
```
|
||||
|
||||
AgentMail doesn't expose a public REST API for sending emails. They require using their official SDK (`agentmail` npm package) instead.
|
||||
|
||||
Additionally, Convex functions that use Node.js packages (like the AgentMail SDK) must run in the Node.js runtime, which requires the `"use node"` directive. However, mutations and queries must run in V8. This created a conflict when trying to use the SDK in the same file as mutations.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Install Official SDK
|
||||
|
||||
```bash
|
||||
npm install agentmail
|
||||
```
|
||||
|
||||
### 2. Split Actions into Separate Files
|
||||
|
||||
Created separate files for Node.js actions:
|
||||
|
||||
- `convex/contactActions.ts` - Contains `sendContactEmail` action with `"use node"`
|
||||
- `convex/newsletterActions.ts` - Contains `sendPostNewsletter` action with `"use node"`
|
||||
|
||||
Main files remain in V8 runtime:
|
||||
|
||||
- `convex/contact.ts` - Contains mutations (`submitContact`, `markEmailSent`)
|
||||
- `convex/newsletter.ts` - Contains mutations and queries
|
||||
|
||||
### 3. Use SDK Instead of REST API
|
||||
|
||||
Replaced fetch calls with the official SDK:
|
||||
|
||||
```typescript
|
||||
// Before (didn't work)
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(emailPayload),
|
||||
});
|
||||
|
||||
// After (works)
|
||||
const client = new AgentMailClient({ apiKey });
|
||||
await client.inboxes.messages.send(inbox, {
|
||||
to: recipientEmail,
|
||||
subject: "...",
|
||||
text: "...",
|
||||
html: "...",
|
||||
});
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `convex/contact.ts` - Removed `"use node"`, kept mutations only
|
||||
- `convex/contactActions.ts` - New file with `sendContactEmail` action using SDK
|
||||
- `convex/newsletter.ts` - Removed `"use node"`, kept mutations/queries only
|
||||
- `convex/newsletterActions.ts` - New file with `sendPostNewsletter` action using SDK
|
||||
- `package.json` - Added `agentmail` dependency
|
||||
|
||||
## Testing
|
||||
|
||||
After the fix:
|
||||
|
||||
1. Contact form submissions store messages in Convex
|
||||
2. Emails send successfully via AgentMail SDK
|
||||
3. No more 404 errors
|
||||
4. Proper error handling and logging
|
||||
|
||||
## Key Learnings
|
||||
|
||||
- AgentMail requires their SDK, not direct REST API calls
|
||||
- Convex actions using Node.js packages need `"use node"` directive
|
||||
- Mutations/queries must run in V8, actions can run in Node.js
|
||||
- Split files by runtime requirement to avoid conflicts
|
||||
|
||||
## References
|
||||
|
||||
- [AgentMail Quickstart](https://docs.agentmail.to/quickstart)
|
||||
- [AgentMail Sending & Receiving Email](https://docs.agentmail.to/sending-receiving-email)
|
||||
- [Convex Actions Documentation](https://docs.convex.dev/functions/actions)
|
||||
159
prds/agentmail-newsletter-v1.md
Normal file
159
prds/agentmail-newsletter-v1.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# AgentMail Newsletter Integration v1
|
||||
|
||||
## Overview
|
||||
|
||||
Email-only newsletter system integrated with AgentMail. All features are optional and controlled via `siteConfig.ts` and frontmatter.
|
||||
|
||||
## Implemented Features
|
||||
|
||||
### Phase 1: Newsletter Signup
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| Site Config | Done | `NewsletterConfig` interface in `siteConfig.ts` |
|
||||
| Schema | Done | `newsletterSubscribers` table with indexes |
|
||||
| Subscribe Mutation | Done | Email validation, duplicate detection, re-subscribe support |
|
||||
| Unsubscribe Mutation | Done | Token verification for security |
|
||||
| Subscriber Queries | Done | `getSubscriberCount`, `getActiveSubscribers` |
|
||||
| NewsletterSignup Component | Done | Email input form with status feedback |
|
||||
| CSS Styling | Done | Responsive styles for all themes |
|
||||
| Home Integration | Done | Configurable position (above-footer, below-intro) |
|
||||
| Blog Page Integration | Done | Configurable position (above-footer, below-posts) |
|
||||
| Post Integration | Done | Frontmatter override support |
|
||||
| Unsubscribe Page | Done | `/unsubscribe` route with auto-processing |
|
||||
|
||||
### Phase 2: Newsletter Sending
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| Sent Posts Schema | Done | `newsletterSentPosts` table to track sent newsletters |
|
||||
| Send Action | Done | `sendPostNewsletter` internalAction using AgentMail API |
|
||||
| CLI Script | Done | `npm run newsletter:send <slug>` |
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files
|
||||
|
||||
- `convex/newsletter.ts` - Subscribe, unsubscribe, and sending functions
|
||||
- `src/components/NewsletterSignup.tsx` - React component
|
||||
- `src/pages/Unsubscribe.tsx` - Unsubscribe page
|
||||
- `scripts/send-newsletter.ts` - CLI tool for sending newsletters
|
||||
- `prds/agentmail-newsletter-v1.md` - This file
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `src/config/siteConfig.ts` - Added `NewsletterConfig` interface
|
||||
- `convex/schema.ts` - Added `newsletterSubscribers` and `newsletterSentPosts` tables
|
||||
- `convex/posts.ts` - Added `getPostBySlugInternal` query
|
||||
- `src/styles/global.css` - Added newsletter component styles
|
||||
- `src/pages/Home.tsx` - Integrated `NewsletterSignup`
|
||||
- `src/pages/Blog.tsx` - Integrated `NewsletterSignup`
|
||||
- `src/pages/Post.tsx` - Integrated `NewsletterSignup` with frontmatter support
|
||||
- `src/App.tsx` - Added `/unsubscribe` route
|
||||
- `package.json` - Added `newsletter:send` script
|
||||
- `fork-config.json.example` - Added newsletter configuration
|
||||
- `FORK_CONFIG.md` - Added newsletter documentation
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables (Convex Dashboard)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
|
||||
| `AGENTMAIL_INBOX` | Your inbox address (e.g., `newsletter@mail.agentmail.to`) |
|
||||
|
||||
### Site Config Example
|
||||
|
||||
```typescript
|
||||
newsletter: {
|
||||
enabled: true,
|
||||
agentmail: {
|
||||
inbox: "newsletter@mail.agentmail.to",
|
||||
},
|
||||
signup: {
|
||||
home: {
|
||||
enabled: true,
|
||||
position: "above-footer",
|
||||
title: "Stay Updated",
|
||||
description: "Get new posts delivered to your inbox.",
|
||||
},
|
||||
blogPage: {
|
||||
enabled: true,
|
||||
position: "above-footer",
|
||||
title: "Subscribe",
|
||||
description: "Get notified when new posts are published.",
|
||||
},
|
||||
posts: {
|
||||
enabled: true,
|
||||
position: "below-content",
|
||||
title: "Enjoyed this post?",
|
||||
description: "Subscribe for more updates.",
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Frontmatter Override
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My Post
|
||||
newsletter: false # Hide newsletter on this post
|
||||
---
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Collect Subscribers
|
||||
|
||||
1. Enable newsletter in `siteConfig.ts`
|
||||
2. Set environment variables in Convex dashboard
|
||||
3. Subscribers can sign up from homepage, blog page, or individual posts
|
||||
|
||||
### Send Newsletter
|
||||
|
||||
```bash
|
||||
# Check post exists and show send command
|
||||
npm run newsletter:send <post-slug>
|
||||
|
||||
# Or use Convex CLI directly
|
||||
npx convex run newsletter:sendPostNewsletter '{"postSlug":"slug","siteUrl":"https://site.com","siteName":"Name"}'
|
||||
```
|
||||
|
||||
### View Subscriber Count
|
||||
|
||||
Subscriber count is available via the `newsletter.getSubscriberCount` query.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### newsletterSubscribers
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| email | string | Subscriber email (lowercase, trimmed) |
|
||||
| subscribed | boolean | Current subscription status |
|
||||
| subscribedAt | number | Timestamp when subscribed |
|
||||
| unsubscribedAt | number? | Timestamp when unsubscribed |
|
||||
| source | string | Signup location ("home", "blog-page", "post:slug") |
|
||||
| unsubscribeToken | string | Secure token for unsubscribe links |
|
||||
|
||||
Indexes: `by_email`, `by_subscribed`
|
||||
|
||||
### newsletterSentPosts
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| postSlug | string | Slug of the sent post |
|
||||
| sentAt | number | Timestamp when sent |
|
||||
| sentCount | number | Number of subscribers sent to |
|
||||
|
||||
Index: `by_postSlug`
|
||||
|
||||
## Future Enhancements (Not Implemented)
|
||||
|
||||
- Double opt-in confirmation emails
|
||||
- Contact form integration
|
||||
- Weekly digest automation
|
||||
- Subscriber admin UI in dashboard
|
||||
- Email templates customization
|
||||
1
public/images/logos/mcp.svg
Normal file
1
public/images/logos/mcp.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>
|
||||
|
After Width: | Height: | Size: 978 B |
@@ -8,6 +8,161 @@ Date: 2025-12-27
|
||||
All notable changes to this project.
|
||||

|
||||
|
||||
## v1.38.0
|
||||
|
||||
Released December 27, 2025
|
||||
|
||||
**Newsletter CLI improvements**
|
||||
|
||||
- `newsletter:send` now calls `scheduleSendPostNewsletter` mutation directly
|
||||
- Sends emails in the background instead of printing instructions
|
||||
- Provides clear success/error feedback
|
||||
- Shows helpful messages about checking Newsletter Admin for results
|
||||
- New `newsletter:send:stats` command
|
||||
- Sends weekly stats summary to your inbox on demand
|
||||
- Uses `scheduleSendStatsSummary` mutation
|
||||
- Email sent to AGENTMAIL_INBOX or AGENTMAIL_CONTACT_EMAIL
|
||||
- New mutation `scheduleSendStatsSummary` in `convex/newsletter.ts`
|
||||
- Allows CLI to trigger stats summary sending
|
||||
- Schedules `sendWeeklyStatsSummary` internal action
|
||||
|
||||
**Documentation**
|
||||
|
||||
- Blog post: "How to use AgentMail with Markdown Sync"
|
||||
- Complete setup guide for AgentMail integration
|
||||
- Environment variables configuration
|
||||
- Newsletter and contact form features
|
||||
- CLI commands documentation
|
||||
- Troubleshooting section
|
||||
- Updated docs.md with new CLI commands
|
||||
- Updated files.md with new script reference
|
||||
- Verified all AgentMail features use environment variables (no hardcoded emails)
|
||||
|
||||
Updated files: `scripts/send-newsletter.ts`, `scripts/send-newsletter-stats.ts`, `convex/newsletter.ts`, `package.json`, `content/blog/how-to-use-agentmail.md`, `content/pages/docs.md`, `files.md`, `changelog.md`, `content/pages/changelog-page.md`, `TASK.md`
|
||||
|
||||
## v1.37.0
|
||||
|
||||
Released December 27, 2025
|
||||
|
||||
**Newsletter Admin UI**
|
||||
|
||||
- Newsletter Admin UI at `/newsletter-admin`
|
||||
- Three-column layout similar to Write page
|
||||
- View all subscribers with search and filter (all/active/unsubscribed)
|
||||
- Stats showing active, total, and sent newsletter counts
|
||||
- Delete subscribers directly from admin
|
||||
- Send newsletter panel with two modes:
|
||||
- Send Post: Select a blog post to send as newsletter
|
||||
- Write Email: Compose custom email with markdown support
|
||||
- Markdown-to-HTML conversion for custom emails (headers, bold, italic, links, lists)
|
||||
- Copy icon on success messages to copy CLI commands
|
||||
- Theme-aware success/error styling (no hardcoded green)
|
||||
- Recent newsletters list showing sent history
|
||||
- Configurable via `siteConfig.newsletterAdmin`
|
||||
|
||||
**Weekly Digest automation**
|
||||
|
||||
- Cron job runs every Sunday at 9:00 AM UTC
|
||||
- Automatically sends all posts published in the last 7 days
|
||||
- Uses AgentMail SDK for email delivery
|
||||
- Configurable via `siteConfig.weeklyDigest`
|
||||
|
||||
**Developer Notifications**
|
||||
|
||||
- New subscriber alerts sent via email when someone subscribes
|
||||
- Weekly stats summary sent every Monday at 9:00 AM UTC
|
||||
- Uses `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` as recipient
|
||||
- Configurable via `siteConfig.newsletterNotifications`
|
||||
|
||||
**Admin queries and mutations**
|
||||
|
||||
- `getAllSubscribers`: Paginated subscriber list with search/filter
|
||||
- `deleteSubscriber`: Remove subscriber from database
|
||||
- `getNewsletterStats`: Stats for admin dashboard
|
||||
- `getPostsForNewsletter`: List of posts with sent status
|
||||
|
||||
Updated files: `convex/newsletter.ts`, `convex/newsletterActions.ts`, `convex/posts.ts`, `convex/crons.ts`, `src/config/siteConfig.ts`, `src/App.tsx`, `src/styles/global.css`, `src/pages/NewsletterAdmin.tsx`
|
||||
|
||||
## v1.36.0
|
||||
|
||||
Released December 27, 2025
|
||||
|
||||
**Social footer component**
|
||||
|
||||
- Social footer component with customizable social links and copyright
|
||||
- Displays social icons on the left (GitHub, Twitter/X, LinkedIn, and more)
|
||||
- Shows copyright symbol, site name, and auto-updating year on the right
|
||||
- Configurable via `siteConfig.socialFooter` in `src/config/siteConfig.ts`
|
||||
- Supports 8 platform types: github, twitter, linkedin, instagram, youtube, tiktok, discord, website
|
||||
- Uses Phosphor icons for consistent styling
|
||||
- Appears below the main footer on homepage, blog posts, and pages
|
||||
- Can work independently of the main footer when set via frontmatter
|
||||
|
||||
**Frontmatter control for social footer**
|
||||
|
||||
- `showSocialFooter` field for posts and pages to override siteConfig defaults
|
||||
- Set `showSocialFooter: false` to hide on specific posts/pages
|
||||
- Works like existing `showFooter` field pattern
|
||||
|
||||
**Social footer configuration options**
|
||||
|
||||
- `enabled`: Global toggle for social footer
|
||||
- `showOnHomepage`, `showOnPosts`, `showOnPages`, `showOnBlogPage`: Per-location visibility
|
||||
- `socialLinks`: Array of social link objects with platform and URL
|
||||
- `copyright.siteName`: Site/company name for copyright display
|
||||
- `copyright.showYear`: Toggle for auto-updating year
|
||||
|
||||
Updated files: `src/config/siteConfig.ts`, `convex/schema.ts`, `convex/posts.ts`, `convex/pages.ts`, `scripts/sync-posts.ts`, `src/pages/Home.tsx`, `src/pages/Post.tsx`, `src/pages/Blog.tsx`, `src/styles/global.css`, `src/components/SocialFooter.tsx`
|
||||
|
||||
## v1.35.0
|
||||
|
||||
Released December 26, 2025
|
||||
|
||||
**Image support at top of posts and pages**
|
||||
|
||||
- `showImageAtTop` frontmatter field for posts and pages
|
||||
- Set `showImageAtTop: true` to display the `image` field at the top of the post/page above the header
|
||||
- Image displays full-width with rounded corners above the post header
|
||||
- Default behavior: if `showImageAtTop` is not set or `false`, image only used for Open Graph previews and featured card thumbnails
|
||||
- Works for both blog posts and static pages
|
||||
- Image appears above the post header when enabled
|
||||
|
||||
Updated files: `convex/schema.ts`, `scripts/sync-posts.ts`, `convex/posts.ts`, `convex/pages.ts`, `src/pages/Post.tsx`, `src/pages/Write.tsx`, `src/styles/global.css`
|
||||
|
||||
Documentation updated: `content/pages/docs.md`, `content/blog/how-to-publish.md`, `content/blog/using-images-in-posts.md`, `files.md`
|
||||
|
||||
## v1.34.0
|
||||
|
||||
Released December 26, 2025
|
||||
|
||||
**Blog page featured layout with hero post**
|
||||
|
||||
- `blogFeatured` frontmatter field for posts to mark as featured on blog page
|
||||
- First `blogFeatured` post displays as hero card with landscape image, tags, date, title, excerpt, author info, and read more link
|
||||
- Remaining `blogFeatured` posts display in 2-column featured row with excerpts
|
||||
- Regular (non-featured) posts display in 3-column grid without excerpts
|
||||
- New `BlogHeroCard` component (`src/components/BlogHeroCard.tsx`) for hero display
|
||||
- New `getBlogFeaturedPosts` query returns all published posts with `blogFeatured: true` sorted by date
|
||||
- `PostList` component updated with `columns` prop (2 or 3) and `showExcerpts` prop
|
||||
- Card images use 16:10 landscape aspect ratio
|
||||
- Footer support on blog page via `siteConfig.footer.showOnBlogPage`
|
||||
|
||||
Updated files: `convex/schema.ts`, `convex/posts.ts`, `scripts/sync-posts.ts`, `src/pages/Blog.tsx`, `src/components/PostList.tsx`, `src/styles/global.css`
|
||||
|
||||
## v1.33.1
|
||||
|
||||
Released December 26, 2025
|
||||
|
||||
**Article centering in sidebar layouts**
|
||||
|
||||
- Article content now centers in the middle column when sidebars are present
|
||||
- Left sidebar stays flush left, right sidebar stays flush right
|
||||
- Article uses `margin-left: auto; margin-right: auto` within its `1fr` grid column
|
||||
- Works with both two-column (left sidebar only) and three-column (both sidebars) layouts
|
||||
- Consistent `max-width: 800px` for article content across all sidebar configurations
|
||||
|
||||
Updated files: `src/styles/global.css`
|
||||
|
||||
## v1.33.0
|
||||
|
||||
Released December 26, 2025
|
||||
|
||||
@@ -7,37 +7,10 @@ Date: 2025-12-27
|
||||
|
||||
You found the contact page. Nice
|
||||
|
||||
<!-- contactform -->
|
||||
|
||||
## The technical way
|
||||
|
||||
This site runs on Convex, which means every page view is a live subscription to the database. You are not reading cached HTML. You are reading data that synced moments ago.
|
||||
|
||||
If you want to reach out, here is an idea: fork this repo, add a contact form, wire it to a Convex mutation, and deploy. Your message will hit the database in under 100ms. No email server required.
|
||||
|
||||
```typescript
|
||||
// A contact form mutation looks like this
|
||||
export const submitContact = mutation({
|
||||
args: {
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
message: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.insert("messages", {
|
||||
...args,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## The human way
|
||||
|
||||
Open an issue on GitHub. Or find the author on X. Or send a carrier pigeon. Convex does not support those yet, but the team is probably working on it.
|
||||
|
||||
## Why Convex
|
||||
|
||||
Traditional backends make you write API routes, manage connections, handle caching, and pray nothing breaks at 3am. Convex handles all of that. You write functions. They run in the cloud. Data syncs to clients. Done.
|
||||
|
||||
The contact form example above is the entire backend. No Express. No database drivers. No WebSocket setup. Just a function that inserts a row.
|
||||
|
||||
That is why this site uses Convex.
|
||||
If you want to reach out, here is an idea: fork this repo, add a contact form, wire it to a Convex mutation, and deploy. Your message will hit the database in under 100ms. No email server required.
|
||||
@@ -98,23 +98,31 @@ image: "/images/og-image.png"
|
||||
Content here...
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | SEO description |
|
||||
| `date` | Yes | YYYY-MM-DD format |
|
||||
| `slug` | Yes | URL path (unique) |
|
||||
| `published` | Yes | `true` to show |
|
||||
| `tags` | Yes | Array of strings |
|
||||
| `readTime` | No | Display time estimate |
|
||||
| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax |
|
||||
| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
|
||||
| Field | Required | Description |
|
||||
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | SEO description |
|
||||
| `date` | Yes | YYYY-MM-DD format |
|
||||
| `slug` | Yes | URL path (unique) |
|
||||
| `published` | Yes | `true` to show |
|
||||
| `tags` | Yes | Array of strings |
|
||||
| `readTime` | No | Display time estimate |
|
||||
| `image` | No | OG image and featured card thumbnail. See [Using Images in Blog Posts](/using-images-in-posts) for markdown and HTML syntax |
|
||||
| `showImageAtTop` | No | Set `true` to display the image at the top of the post above the header (default: `false`) |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
|
||||
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
|
||||
| `showFooter` | No | Show footer on this post (overrides siteConfig default) |
|
||||
| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) |
|
||||
| `showSocialFooter` | No | Show social footer on this post (overrides siteConfig default) |
|
||||
| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
|
||||
| `blogFeatured` | No | Show as featured on blog page (first becomes hero, rest in 2-column row) |
|
||||
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
|
||||
| `contactForm` | No | Enable contact form on this post |
|
||||
|
||||
### Static pages
|
||||
|
||||
@@ -131,22 +139,28 @@ order: 1
|
||||
Content here...
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------- |
|
||||
| `title` | Yes | Nav link text |
|
||||
| `slug` | Yes | URL path |
|
||||
| `published` | Yes | `true` to show |
|
||||
| `order` | No | Nav order (lower = first) |
|
||||
| `showInNav` | No | Show in navigation menu (default: `true`) |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `image` | No | Thumbnail for featured card view |
|
||||
| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
|
||||
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
|
||||
| Field | Required | Description |
|
||||
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `title` | Yes | Nav link text |
|
||||
| `slug` | Yes | URL path |
|
||||
| `published` | Yes | `true` to show |
|
||||
| `order` | No | Nav order (lower = first) |
|
||||
| `showInNav` | No | Show in navigation menu (default: `true`) |
|
||||
| `excerpt` | No | Short text for card view |
|
||||
| `image` | No | Thumbnail for featured card view |
|
||||
| `showImageAtTop` | No | Set `true` to display the image at the top of the page above the header (default: `false`) |
|
||||
| `featured` | No | `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `layout` | No | Set to `"sidebar"` for docs-style layout with TOC |
|
||||
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
|
||||
| `showFooter` | No | Show footer on this page (overrides siteConfig default) |
|
||||
| `footer` | No | Footer markdown content (overrides siteConfig.defaultContent) |
|
||||
| `showSocialFooter` | No | Show social footer on this page (overrides siteConfig default) |
|
||||
| `aiChat` | No | Enable AI chat in right sidebar. Set `true` to enable (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`). Set `false` to explicitly hide even if global config is enabled. |
|
||||
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
|
||||
| `contactForm` | No | Enable contact form on this page |
|
||||
|
||||
**Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav.
|
||||
|
||||
@@ -799,11 +813,86 @@ The `/stats` page displays real-time analytics:
|
||||
|
||||
All stats update automatically via Convex subscriptions.
|
||||
|
||||
## Newsletter Admin
|
||||
|
||||
The Newsletter Admin page at `/newsletter-admin` provides a UI for managing subscribers and sending newsletters.
|
||||
|
||||
**Features:**
|
||||
|
||||
- View and search all subscribers (search bar in header)
|
||||
- Filter by status (all, active, unsubscribed)
|
||||
- Delete subscribers
|
||||
- Send blog posts as newsletters
|
||||
- Write and send custom emails with markdown support
|
||||
- View recent newsletter sends (last 10, includes both posts and custom emails)
|
||||
- Email statistics dashboard with:
|
||||
- Total emails sent
|
||||
- Newsletters sent count
|
||||
- Active subscribers
|
||||
- Retention rate
|
||||
- Detailed summary table
|
||||
|
||||
**Configuration:**
|
||||
|
||||
Enable in `src/config/siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
newsletterAdmin: {
|
||||
enabled: true, // Enable /newsletter-admin route
|
||||
showInNav: false, // Hide from navigation (access via direct URL)
|
||||
},
|
||||
```
|
||||
|
||||
**Environment Variables (Convex):**
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------- | --------------------------------------------------- |
|
||||
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
|
||||
| `AGENTMAIL_INBOX` | Your AgentMail inbox (e.g., `inbox@agentmail.to`) |
|
||||
| `AGENTMAIL_CONTACT_EMAIL` | Optional contact form recipient (defaults to inbox) |
|
||||
|
||||
**Note:** If environment variables are not configured, users will see the error message: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX." when attempting to send newsletters or use contact forms.
|
||||
|
||||
**Sending Newsletters:**
|
||||
|
||||
The admin UI supports two sending modes:
|
||||
|
||||
1. **Send Post**: Select a published blog post to send as a newsletter
|
||||
2. **Write Email**: Compose a custom email with markdown formatting
|
||||
|
||||
Custom emails support markdown syntax:
|
||||
|
||||
- `# Heading` for headers
|
||||
- `**bold**` and `*italic*` for emphasis
|
||||
- `[link text](url)` for links
|
||||
- `- item` for bullet lists
|
||||
|
||||
**CLI Commands:**
|
||||
|
||||
You can send newsletters via command line:
|
||||
|
||||
```bash
|
||||
# Send a blog post to all subscribers
|
||||
npm run newsletter:send <post-slug>
|
||||
|
||||
# Send weekly stats summary to your inbox
|
||||
npm run newsletter:send:stats
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
npm run newsletter:send setup-guide
|
||||
```
|
||||
|
||||
The `newsletter:send` command calls the `scheduleSendPostNewsletter` mutation directly and sends emails in the background. Check the Newsletter Admin page or recent sends to see results.
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
| ------------------------------ | --------------------------- |
|
||||
| `/stats` | Real-time analytics |
|
||||
| `/newsletter-admin` | Newsletter management UI |
|
||||
| `/rss.xml` | RSS feed (descriptions) |
|
||||
| `/rss-full.xml` | RSS feed (full content) |
|
||||
| `/sitemap.xml` | XML sitemap |
|
||||
|
||||
242
public/raw/how-to-use-agentmail.md
Normal file
242
public/raw/how-to-use-agentmail.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# How to use AgentMail with Markdown Sync
|
||||
|
||||
> Complete guide to setting up AgentMail for newsletters and contact forms in your markdown blog
|
||||
|
||||
---
|
||||
Type: post
|
||||
Date: 2025-12-27
|
||||
Reading time: 5 min read
|
||||
Tags: agentmail, newsletter, email, setup
|
||||
---
|
||||
|
||||
AgentMail provides email infrastructure for your markdown blog, enabling newsletter subscriptions, contact forms, and automated email notifications. This guide covers setup, configuration, and usage.
|
||||
|
||||
## What is AgentMail
|
||||
|
||||
AgentMail is an email service designed for AI agents and developers. It handles email sending and receiving without OAuth or MFA requirements, making it ideal for automated workflows.
|
||||
|
||||
For this markdown blog framework, AgentMail powers:
|
||||
|
||||
- Newsletter subscriptions and sending
|
||||
- Contact forms on posts and pages
|
||||
- Developer notifications for new subscribers
|
||||
- Weekly digest emails
|
||||
- Weekly stats summaries
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create an AgentMail account
|
||||
|
||||
Sign up at [agentmail.to](https://agentmail.to) and create an inbox. Your inbox address will look like `yourname@agentmail.to`.
|
||||
|
||||
### 2. Get your API key
|
||||
|
||||
In the AgentMail dashboard, navigate to API settings and copy your API key. You'll need this for Convex environment variables.
|
||||
|
||||
### 3. Configure Convex environment variables
|
||||
|
||||
In your Convex dashboard, go to Settings > Environment Variables and add:
|
||||
|
||||
| Variable | Description | Required |
|
||||
|----------|-------------|----------|
|
||||
| `AGENTMAIL_API_KEY` | Your AgentMail API key | Yes |
|
||||
| `AGENTMAIL_INBOX` | Your inbox address (e.g., `markdown@agentmail.to`) | Yes |
|
||||
| `AGENTMAIL_CONTACT_EMAIL` | Contact form recipient (defaults to inbox if not set) | No |
|
||||
|
||||
**Important:** Never hardcode email addresses in your code. Always use environment variables.
|
||||
|
||||
### 4. Enable features in siteConfig
|
||||
|
||||
Edit `src/config/siteConfig.ts` to enable newsletter and contact form features:
|
||||
|
||||
```typescript
|
||||
newsletter: {
|
||||
enabled: true,
|
||||
showOnHomepage: true,
|
||||
showOnBlogPage: true,
|
||||
showOnPosts: true,
|
||||
title: "Subscribe to the newsletter",
|
||||
description: "Get updates delivered to your inbox",
|
||||
},
|
||||
|
||||
contactForm: {
|
||||
enabled: true,
|
||||
title: "Get in touch",
|
||||
description: "Send us a message",
|
||||
},
|
||||
```
|
||||
|
||||
## Newsletter features
|
||||
|
||||
### Subscriber management
|
||||
|
||||
The Newsletter Admin page at `/newsletter-admin` provides:
|
||||
|
||||
- View all subscribers with search and filters
|
||||
- Delete subscribers
|
||||
- Send blog posts as newsletters
|
||||
- Write and send custom emails with markdown support
|
||||
- View email statistics dashboard
|
||||
- Track recent sends (last 10)
|
||||
|
||||
### Sending newsletters
|
||||
|
||||
**Via CLI:**
|
||||
|
||||
```bash
|
||||
# Send a specific post to all subscribers
|
||||
npm run newsletter:send setup-guide
|
||||
|
||||
# Send weekly stats summary to your inbox
|
||||
npm run newsletter:send:stats
|
||||
```
|
||||
|
||||
**Via Admin UI:**
|
||||
|
||||
1. Navigate to `/newsletter-admin`
|
||||
2. Select "Send Post" or "Write Email" from the sidebar
|
||||
3. Choose a post or compose a custom email
|
||||
4. Click "Send Newsletter"
|
||||
|
||||
### Weekly digest
|
||||
|
||||
Automated weekly digest emails are sent every Sunday at 9:00 AM UTC. They include all posts published in the last 7 days.
|
||||
|
||||
Configure in `siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
weeklyDigest: {
|
||||
enabled: true,
|
||||
},
|
||||
```
|
||||
|
||||
### Developer notifications
|
||||
|
||||
Receive email notifications when:
|
||||
|
||||
- A new subscriber signs up
|
||||
- Weekly stats summary (every Monday at 9:00 AM UTC)
|
||||
|
||||
Configure in `siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
newsletterNotifications: {
|
||||
enabled: true,
|
||||
},
|
||||
```
|
||||
|
||||
Notifications are sent to `AGENTMAIL_CONTACT_EMAIL` or `AGENTMAIL_INBOX` if contact email is not set.
|
||||
|
||||
## Contact forms
|
||||
|
||||
### Enable on posts and pages
|
||||
|
||||
Add `contactForm: true` to any post or page frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Contact Us"
|
||||
slug: "contact"
|
||||
published: true
|
||||
contactForm: true
|
||||
---
|
||||
|
||||
Your page content here...
|
||||
```
|
||||
|
||||
The contact form includes:
|
||||
|
||||
- Name field
|
||||
- Email field
|
||||
- Message field
|
||||
|
||||
Submissions are stored in Convex and sent via AgentMail to your configured recipient.
|
||||
|
||||
### Frontmatter options
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `contactForm` | boolean | Enable contact form on this post/page |
|
||||
|
||||
## Frontmatter options
|
||||
|
||||
### Newsletter signup
|
||||
|
||||
Control newsletter signup display per post/page:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "My Post"
|
||||
newsletter: true # Show signup (default: follows siteConfig)
|
||||
---
|
||||
```
|
||||
|
||||
Or hide it:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "My Post"
|
||||
newsletter: false # Hide signup even if enabled globally
|
||||
---
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
All AgentMail features require these Convex environment variables:
|
||||
|
||||
**Required:**
|
||||
|
||||
- `AGENTMAIL_API_KEY` - Your AgentMail API key
|
||||
- `AGENTMAIL_INBOX` - Your inbox address
|
||||
|
||||
**Optional:**
|
||||
|
||||
- `AGENTMAIL_CONTACT_EMAIL` - Contact form recipient (defaults to inbox)
|
||||
|
||||
**Note:** If environment variables are not configured, users will see: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX."
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run newsletter:send <slug>` | Send a blog post to all subscribers |
|
||||
| `npm run newsletter:send:stats` | Send weekly stats summary to your inbox |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Emails not sending:**
|
||||
|
||||
1. Verify `AGENTMAIL_API_KEY` and `AGENTMAIL_INBOX` are set in Convex dashboard
|
||||
2. Check Convex function logs for error messages
|
||||
3. Ensure your inbox is active in AgentMail dashboard
|
||||
|
||||
**Contact form not appearing:**
|
||||
|
||||
1. Verify `contactForm: true` is in frontmatter
|
||||
2. Check `siteConfig.contactForm.enabled` is `true`
|
||||
3. Run `npm run sync` to sync frontmatter changes
|
||||
|
||||
**Newsletter Admin not accessible:**
|
||||
|
||||
1. Verify `siteConfig.newsletterAdmin.enabled` is `true`
|
||||
2. Navigate to `/newsletter-admin` directly (hidden from nav by default)
|
||||
|
||||
## Resources
|
||||
|
||||
- [AgentMail Documentation](https://docs.agentmail.to)
|
||||
- [AgentMail Quickstart](https://docs.agentmail.to/quickstart)
|
||||
- [AgentMail Sending & Receiving Email](https://docs.agentmail.to/sending-receiving-email)
|
||||
- [AgentMail Inboxes](https://docs.agentmail.to/inboxes)
|
||||
|
||||
## Summary
|
||||
|
||||
AgentMail integration provides:
|
||||
|
||||
- Newsletter subscriptions and sending
|
||||
- Contact forms on any post or page
|
||||
- Automated weekly digests
|
||||
- Developer notifications
|
||||
- Admin UI for subscriber management
|
||||
- CLI tools for sending newsletters and stats
|
||||
|
||||
All features use Convex environment variables for configuration. No hardcoded emails in your codebase.
|
||||
66
public/raw/how-to-use-firecrawl.md
Normal file
66
public/raw/how-to-use-firecrawl.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# How to use Firecrawl
|
||||
|
||||
> Import external articles as markdown posts using Firecrawl. Get your API key and configure environment variables for local imports and AI chat.
|
||||
|
||||
---
|
||||
Type: post
|
||||
Date: 2025-01-20
|
||||
Reading time: 2 min read
|
||||
Tags: tutorial, firecrawl, import
|
||||
---
|
||||
|
||||
# How to use Firecrawl
|
||||
|
||||
You found an article you want to republish or reference. Copying content manually takes time. Firecrawl scrapes web pages and converts them to markdown automatically.
|
||||
|
||||
## What it is
|
||||
|
||||
Firecrawl is a web scraping service that turns any URL into clean markdown. This app uses it in two places: the import script for creating draft posts, and the AI chat feature for fetching page content.
|
||||
|
||||
## Who it's for
|
||||
|
||||
Developers who want to import external articles without manual copying. If you republish content or need to reference external sources, Firecrawl saves time.
|
||||
|
||||
## The problem it solves
|
||||
|
||||
Manually copying content from websites is slow. You copy text, fix formatting, add frontmatter, and handle images. Firecrawl does this automatically.
|
||||
|
||||
## How it works
|
||||
|
||||
The import script scrapes a URL, extracts the title and description, converts HTML to markdown, and creates a draft post in `content/blog/`. The AI chat feature uses Firecrawl to fetch page content when you share URLs in conversations.
|
||||
|
||||
## How to try it
|
||||
|
||||
**Step 1: Get your API key**
|
||||
|
||||
Visit [firecrawl.dev](https://firecrawl.dev) and sign up. Copy your API key. It starts with `fc-`.
|
||||
|
||||
**Step 2: Set up local imports**
|
||||
|
||||
Add the key to `.env.local` in your project root:
|
||||
|
||||
```
|
||||
FIRECRAWL_API_KEY=fc-your-api-key-here
|
||||
```
|
||||
|
||||
Now you can import articles:
|
||||
|
||||
```bash
|
||||
npm run import https://example.com/article
|
||||
```
|
||||
|
||||
This creates a draft post in `content/blog/`. Review it, set `published: true`, then run `npm run sync`.
|
||||
|
||||
**Step 3: Enable AI chat scraping**
|
||||
|
||||
If you use the AI chat feature, set the same key in your Convex Dashboard:
|
||||
|
||||
1. Go to [dashboard.convex.dev](https://dashboard.convex.dev)
|
||||
2. Select your project
|
||||
3. Open Settings > Environment Variables
|
||||
4. Add `FIRECRAWL_API_KEY` with your key value
|
||||
5. Deploy: `npx convex deploy`
|
||||
|
||||
The AI chat can now fetch content from URLs you share.
|
||||
|
||||
That's it. One API key, two places to set it, and you're done.
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
This is the homepage index of all published content.
|
||||
|
||||
## Blog Posts (12)
|
||||
## Blog Posts (14)
|
||||
|
||||
- **[How to use AgentMail with Markdown Sync](/raw/how-to-use-agentmail.md)** - Complete guide to setting up AgentMail for newsletters and contact forms in your markdown blog
|
||||
- Date: 2025-12-27 | Reading time: 5 min read | Tags: agentmail, newsletter, email, setup
|
||||
- **[Happy holidays and thank you](/raw/happy-holidays-2025.md)** - A quick note of thanks for stars, forks, and feedback. More AI-first publishing features coming in 2026.
|
||||
- Date: 2025-12-25 | Reading time: 2 min read | Tags: updates, community, ai
|
||||
- **[Netlify edge functions blocking AI crawlers from static files](/raw/netlify-edge-excludedpath-ai-crawlers.md)** - Why excludedPath in netlify.toml isn't preventing edge functions from intercepting /raw/* requests, and how ChatGPT and Perplexity get blocked while Claude works.
|
||||
@@ -26,19 +28,22 @@ This is the homepage index of all published content.
|
||||
- Date: 2025-12-14 | Reading time: 8 min read | Tags: convex, netlify, tutorial, deployment
|
||||
- **[Using Images in Blog Posts](/raw/using-images-in-posts.md)** - Learn how to add header images, inline images, and Open Graph images to your markdown posts.
|
||||
- Date: 2025-12-14 | Reading time: 4 min read | Tags: images, tutorial, markdown, open-graph
|
||||
- **[How to use Firecrawl](/raw/how-to-use-firecrawl.md)** - Import external articles as markdown posts using Firecrawl. Get your API key and configure environment variables for local imports and AI chat.
|
||||
- Date: 2025-01-20 | Reading time: 2 min read | Tags: tutorial, firecrawl, import
|
||||
- **[Git commit message best practices](/raw/git-commit-message-best-practices.md)** - A guide to writing clear, consistent commit messages that help your team understand changes and generate better changelogs.
|
||||
- Date: 2025-01-17 | Reading time: 5 min read | Tags: git, development, best-practices, workflow
|
||||
|
||||
## Pages (5)
|
||||
## Pages (6)
|
||||
|
||||
- **[Docs](/raw/docs.md)**
|
||||
- **[About](/raw/about.md)** - An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs..
|
||||
- **[Projects](/raw/projects.md)**
|
||||
- **[Contact](/raw/contact.md)**
|
||||
- **[Changelog](/raw/changelog.md)**
|
||||
- **[Newsletter](/raw/newsletter.md)**
|
||||
|
||||
---
|
||||
|
||||
**Total Content:** 12 posts, 5 pages
|
||||
**Total Content:** 14 posts, 6 pages
|
||||
|
||||
All content is available as raw markdown files at `/raw/{slug}.md`
|
||||
|
||||
28
public/raw/newsletter.md
Normal file
28
public/raw/newsletter.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Newsletter
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2025-12-27
|
||||
---
|
||||
|
||||
# Newsletter
|
||||
|
||||
Stay updated with the latest posts and updates from the markdown sync framework.
|
||||
|
||||
## What you will get
|
||||
|
||||
When you subscribe, you will receive:
|
||||
|
||||
- Notifications when new blog posts are published
|
||||
- Updates about new features and improvements
|
||||
- Tips and tricks for getting the most out of markdown sync
|
||||
|
||||
## Subscribe
|
||||
|
||||
Use the form below to subscribe to our newsletter. We respect your privacy and you can unsubscribe at any time.
|
||||
|
||||
## Privacy
|
||||
|
||||
We only use your email address to send you newsletter updates. We never share your email with third parties or use it for any other purpose.
|
||||
|
||||
To unsubscribe, click the unsubscribe link at the bottom of any newsletter email.
|
||||
@@ -56,6 +56,7 @@ This guide walks you through forking [this markdown framework](https://github.co
|
||||
- [Visitor Map](#visitor-map)
|
||||
- [Logo Gallery](#logo-gallery)
|
||||
- [Blog page](#blog-page)
|
||||
- [Hardcoded Navigation Items](#hardcoded-navigation-items)
|
||||
- [Scroll-to-top button](#scroll-to-top-button)
|
||||
- [Change the Default Theme](#change-the-default-theme)
|
||||
- [Change the Font](#change-the-font)
|
||||
@@ -77,6 +78,7 @@ This guide walks you through forking [this markdown framework](https://github.co
|
||||
- [Build failures on Netlify](#build-failures-on-netlify)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Write Page](#write-page)
|
||||
- [AI Agent chat](#ai-agent-chat)
|
||||
- [Next Steps](#next-steps)
|
||||
|
||||
## Prerequisites
|
||||
@@ -181,6 +183,7 @@ export default defineSchema({
|
||||
Blog posts live in `content/blog/` as markdown files. Sync them to Convex:
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
npm run sync # Sync markdown content
|
||||
npm run sync:discovery # Update discovery files (AGENTS.md, llms.txt)
|
||||
@@ -188,6 +191,7 @@ npm run sync:all # Sync content + discovery files together
|
||||
```
|
||||
|
||||
**Production:**
|
||||
|
||||
```bash
|
||||
npm run sync:prod # Sync markdown content
|
||||
npm run sync:discovery:prod # Update discovery files
|
||||
@@ -324,21 +328,21 @@ Your markdown content here...
|
||||
|
||||
### Frontmatter Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | Short description for SEO |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | Yes | URL path (must be unique) |
|
||||
| `published` | Yes | Set to `true` to publish |
|
||||
| `tags` | Yes | Array of topic tags |
|
||||
| `readTime` | No | Estimated reading time |
|
||||
| `image` | No | Header/Open Graph image URL |
|
||||
| `excerpt` | No | Short excerpt for card view |
|
||||
| `featured` | No | Set `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured section (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | Short description for SEO |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
| `slug` | Yes | URL path (must be unique) |
|
||||
| `published` | Yes | Set to `true` to publish |
|
||||
| `tags` | Yes | Array of topic tags |
|
||||
| `readTime` | No | Estimated reading time |
|
||||
| `image` | No | Header/Open Graph image URL |
|
||||
| `excerpt` | No | Short excerpt for card view |
|
||||
| `featured` | No | Set `true` to show in featured section |
|
||||
| `featuredOrder` | No | Order in featured section (lower = first) |
|
||||
| `authorName` | No | Author display name shown next to date |
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
|
||||
|
||||
### How Frontmatter Works
|
||||
@@ -403,6 +407,7 @@ The `npm run sync` command only syncs markdown text content. Images are deployed
|
||||
After adding or editing posts, sync to Convex.
|
||||
|
||||
**Development sync:**
|
||||
|
||||
```bash
|
||||
npm run sync # Sync markdown content
|
||||
npm run sync:discovery # Update discovery files
|
||||
@@ -420,6 +425,7 @@ VITE_CONVEX_URL=https://your-prod-deployment.convex.cloud
|
||||
Get your production URL from the [Convex Dashboard](https://dashboard.convex.dev) by selecting your project and switching to the Production deployment.
|
||||
|
||||
Then sync:
|
||||
|
||||
```bash
|
||||
npm run sync:prod # Sync markdown content
|
||||
npm run sync:discovery:prod # Update discovery files
|
||||
@@ -437,17 +443,17 @@ Both files are gitignored. Each developer creates their own local environment fi
|
||||
|
||||
### When to Sync vs Deploy
|
||||
|
||||
| What you're changing | Command | Timing |
|
||||
| -------------------------------- | -------------------------- | -------------------- |
|
||||
| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) |
|
||||
| What you're changing | Command | Timing |
|
||||
| -------------------------------- | -------------------------- | ----------------------- |
|
||||
| Blog posts in `content/blog/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Pages in `content/pages/` | `npm run sync` | Instant (no rebuild) |
|
||||
| Featured items (via frontmatter) | `npm run sync` | Instant (no rebuild) |
|
||||
| Site config changes | `npm run sync:discovery` | Updates discovery files |
|
||||
| Import external URL | `npm run import` then sync | Instant (no rebuild) |
|
||||
| Images in `public/images/` | Git commit + push | Requires rebuild |
|
||||
| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild |
|
||||
| Logo gallery config | Redeploy | Requires rebuild |
|
||||
| React components/styles | Redeploy | Requires rebuild |
|
||||
| Import external URL | `npm run import` then sync | Instant (no rebuild) |
|
||||
| Images in `public/images/` | Git commit + push | Requires rebuild |
|
||||
| `siteConfig` in `Home.tsx` | Redeploy | Requires rebuild |
|
||||
| Logo gallery config | Redeploy | Requires rebuild |
|
||||
| React components/styles | Redeploy | Requires rebuild |
|
||||
|
||||
**Markdown content** syncs instantly via Convex. **Images and source code** require pushing to GitHub for Netlify to rebuild.
|
||||
|
||||
@@ -965,15 +971,12 @@ body {
|
||||
serif;
|
||||
|
||||
/* Monospace */
|
||||
font-family:
|
||||
"IBM Plex Mono",
|
||||
"Liberation Mono",
|
||||
ui-monospace,
|
||||
monospace;
|
||||
font-family: "IBM Plex Mono", "Liberation Mono", ui-monospace, monospace;
|
||||
}
|
||||
```
|
||||
|
||||
Available font options:
|
||||
|
||||
- `serif`: New York serif font (default)
|
||||
- `sans`: System sans-serif fonts
|
||||
- `monospace`: IBM Plex Mono monospace font
|
||||
@@ -1097,6 +1100,49 @@ How it works:
|
||||
- A cron job cleans up stale sessions every 5 minutes
|
||||
- No personal data is stored (only anonymous UUIDs)
|
||||
|
||||
## Newsletter Admin
|
||||
|
||||
A newsletter management interface is available at `/newsletter-admin`. Use it to view subscribers, send newsletters, and compose custom emails.
|
||||
|
||||
**Features:**
|
||||
|
||||
- View and search all subscribers with filtering options (search bar in header)
|
||||
- Delete subscribers from the admin UI
|
||||
- Send published blog posts as newsletters
|
||||
- Write custom emails using markdown formatting
|
||||
- View recent newsletter sends (last 10, tracks both posts and custom emails)
|
||||
- Email statistics dashboard with comprehensive metrics
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Enable in `src/config/siteConfig.ts`:
|
||||
|
||||
```typescript
|
||||
newsletterAdmin: {
|
||||
enabled: true,
|
||||
showInNav: false, // Keep hidden, access via direct URL
|
||||
},
|
||||
```
|
||||
|
||||
2. Set environment variables in Convex Dashboard:
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------- | ------------------------------------ |
|
||||
| `AGENTMAIL_API_KEY` | Your AgentMail API key |
|
||||
| `AGENTMAIL_INBOX` | Your AgentMail inbox address |
|
||||
| `AGENTMAIL_CONTACT_EMAIL` | Optional recipient for contact forms |
|
||||
|
||||
**Important:** If environment variables are not configured, users will see an error message when attempting to use newsletter or contact form features: "AgentMail Environment Variables are not configured in production. Please set AGENTMAIL_API_KEY and AGENTMAIL_INBOX."
|
||||
|
||||
**Sending newsletters:**
|
||||
|
||||
Two modes are available:
|
||||
|
||||
1. **Send Post**: Select a blog post to send to all active subscribers
|
||||
2. **Write Email**: Compose custom content with markdown support
|
||||
|
||||
The admin UI shows send results and provides CLI commands as alternatives.
|
||||
|
||||
## Mobile Navigation
|
||||
|
||||
On mobile and tablet screens (under 768px), a hamburger menu provides navigation. The menu slides out from the left with keyboard navigation (Escape to close) and a focus trap for accessibility. It auto-closes when you navigate to a new route.
|
||||
@@ -1105,23 +1151,23 @@ On mobile and tablet screens (under 768px), a hamburger menu provides navigation
|
||||
|
||||
Each post and page includes a share dropdown with options for AI tools:
|
||||
|
||||
| Option | Description |
|
||||
| -------------------- | ------------------------------------------------- |
|
||||
| Copy page | Copies formatted markdown to clipboard |
|
||||
| Open in ChatGPT | Opens ChatGPT with raw markdown URL |
|
||||
| Open in Claude | Opens Claude with raw markdown URL |
|
||||
| Open in Perplexity | Opens Perplexity with raw markdown URL |
|
||||
| View as Markdown | Opens raw `.md` file in new tab |
|
||||
| Download as SKILL.md | Downloads skill file for AI agent training |
|
||||
| Option | Description |
|
||||
| -------------------- | ------------------------------------------ |
|
||||
| Copy page | Copies formatted markdown to clipboard |
|
||||
| Open in ChatGPT | Opens ChatGPT with raw markdown URL |
|
||||
| Open in Claude | Opens Claude with raw markdown URL |
|
||||
| Open in Perplexity | Opens Perplexity with raw markdown URL |
|
||||
| View as Markdown | Opens raw `.md` file in new tab |
|
||||
| Download as SKILL.md | Downloads skill file for AI agent training |
|
||||
|
||||
**Git push required for AI links:** The "Open in ChatGPT," "Open in Claude," and "Open in Perplexity" options use GitHub raw URLs to fetch content. For these to work, your content must be pushed to GitHub with `git push`. The `npm run sync` command syncs content to Convex for your live site, but AI services fetch directly from GitHub.
|
||||
|
||||
| What you want | Command needed |
|
||||
| ------------------------------------ | ------------------------------ |
|
||||
| Content visible on your site | `npm run sync` or `sync:prod` |
|
||||
| What you want | Command needed |
|
||||
| ------------------------------------ | ------------------------------------------------- |
|
||||
| Content visible on your site | `npm run sync` or `sync:prod` |
|
||||
| Discovery files updated | `npm run sync:discovery` or `sync:discovery:prod` |
|
||||
| AI links (ChatGPT/Claude/Perplexity) | `git push` to GitHub |
|
||||
| Both content and discovery | `npm run sync:all` or `sync:all:prod` |
|
||||
| AI links (ChatGPT/Claude/Perplexity) | `git push` to GitHub |
|
||||
| Both content and discovery | `npm run sync:all` or `sync:all:prod` |
|
||||
|
||||
**Download as SKILL.md** formats the content as an Anthropic Agent Skills file with metadata, triggers, and instructions sections.
|
||||
|
||||
@@ -1319,7 +1365,7 @@ Enable Agent in the right sidebar on individual posts or pages using the `aiChat
|
||||
---
|
||||
title: "My Post"
|
||||
rightSidebar: true
|
||||
aiChat: true # Enable Agent in right sidebar
|
||||
aiChat: true # Enable Agent in right sidebar
|
||||
---
|
||||
```
|
||||
|
||||
|
||||
63
scripts/send-newsletter-stats.ts
Normal file
63
scripts/send-newsletter-stats.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
|
||||
/**
|
||||
* Send weekly stats summary email to developer
|
||||
*
|
||||
* Usage:
|
||||
* npm run newsletter:send:stats
|
||||
*
|
||||
* Environment variables (from .env.local):
|
||||
* - VITE_CONVEX_URL: Convex deployment URL
|
||||
* - SITE_NAME: Your site name (default: "Newsletter")
|
||||
*
|
||||
* Note: AGENTMAIL_API_KEY, AGENTMAIL_INBOX, and AGENTMAIL_CONTACT_EMAIL must be set in Convex dashboard
|
||||
*/
|
||||
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { api } from "../convex/_generated/api";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env.production.local" });
|
||||
|
||||
async function main() {
|
||||
const convexUrl = process.env.VITE_CONVEX_URL;
|
||||
if (!convexUrl) {
|
||||
console.error(
|
||||
"Error: VITE_CONVEX_URL not found. Run 'npx convex dev' to create .env.local"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const siteName = process.env.SITE_NAME || "Newsletter";
|
||||
|
||||
console.log("Sending weekly stats summary...");
|
||||
console.log(`Site name: ${siteName}`);
|
||||
console.log("");
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl);
|
||||
|
||||
try {
|
||||
// Call the mutation to schedule the stats summary send
|
||||
const result = await client.mutation(api.newsletter.scheduleSendStatsSummary, {
|
||||
siteName,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log("✓ Stats summary scheduled successfully!");
|
||||
console.log(result.message);
|
||||
console.log("");
|
||||
console.log("The email will be sent to AGENTMAIL_INBOX (or AGENTMAIL_CONTACT_EMAIL if set).");
|
||||
} else {
|
||||
console.error("✗ Failed to send stats summary:");
|
||||
console.error(result.message);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
101
scripts/send-newsletter.ts
Normal file
101
scripts/send-newsletter.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
|
||||
/**
|
||||
* Send newsletter for a specific post to all subscribers
|
||||
*
|
||||
* Usage:
|
||||
* npm run newsletter:send <post-slug>
|
||||
*
|
||||
* Example:
|
||||
* npm run newsletter:send setup-guide
|
||||
*
|
||||
* Environment variables (from .env.local):
|
||||
* - VITE_CONVEX_URL: Convex deployment URL
|
||||
* - SITE_URL: Your site URL (default: https://markdown.fast)
|
||||
* - SITE_NAME: Your site name (default: "Newsletter")
|
||||
*
|
||||
* Note: AGENTMAIL_API_KEY and AGENTMAIL_INBOX must be set in Convex dashboard
|
||||
*/
|
||||
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { api } from "../convex/_generated/api";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env.production.local" });
|
||||
|
||||
async function main() {
|
||||
const postSlug = process.argv[2];
|
||||
|
||||
if (!postSlug) {
|
||||
console.error("Usage: npm run newsletter:send <post-slug>");
|
||||
console.error("Example: npm run newsletter:send setup-guide");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const convexUrl = process.env.VITE_CONVEX_URL;
|
||||
if (!convexUrl) {
|
||||
console.error(
|
||||
"Error: VITE_CONVEX_URL not found. Run 'npx convex dev' to create .env.local"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const siteUrl = process.env.SITE_URL || "https://markdown.fast";
|
||||
const siteName = process.env.SITE_NAME || "Newsletter";
|
||||
|
||||
console.log(`Sending newsletter for post: ${postSlug}`);
|
||||
console.log(`Site URL: ${siteUrl}`);
|
||||
console.log(`Site name: ${siteName}`);
|
||||
console.log("");
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl);
|
||||
|
||||
try {
|
||||
// First check if post exists
|
||||
const post = await client.query(api.posts.getPostBySlug, { slug: postSlug });
|
||||
if (!post) {
|
||||
console.error(`Error: Post "${postSlug}" not found or not published.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Found post: "${post.title}"`);
|
||||
|
||||
// Get subscriber count first
|
||||
const subscriberCount = await client.query(api.newsletter.getSubscriberCount);
|
||||
console.log(`Active subscribers: ${subscriberCount}`);
|
||||
|
||||
if (subscriberCount === 0) {
|
||||
console.log("No subscribers to send to.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("Sending newsletter...");
|
||||
|
||||
// Call the mutation directly to schedule the newsletter send
|
||||
const result = await client.mutation(api.newsletter.scheduleSendPostNewsletter, {
|
||||
postSlug,
|
||||
siteUrl,
|
||||
siteName,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log("✓ Newsletter scheduled successfully!");
|
||||
console.log(result.message);
|
||||
console.log("");
|
||||
console.log("The newsletter is being sent in the background.");
|
||||
console.log("Check the Newsletter Admin page or recent sends to see results.");
|
||||
} else {
|
||||
console.error("✗ Failed to send newsletter:");
|
||||
console.error(result.message);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -39,8 +39,13 @@ interface PostFrontmatter {
|
||||
authorImage?: string; // Author avatar image URL (round)
|
||||
layout?: string; // Layout type: "sidebar" for docs-style layout
|
||||
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
|
||||
showFooter?: boolean; // Show footer on this post (overrides siteConfig default)
|
||||
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
|
||||
showSocialFooter?: boolean; // Show social footer on this post (overrides siteConfig default)
|
||||
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
|
||||
blogFeatured?: boolean; // Show as hero featured post on /blog page
|
||||
newsletter?: boolean; // Override newsletter signup display (true/false)
|
||||
contactForm?: boolean; // Enable contact form on this post
|
||||
}
|
||||
|
||||
interface ParsedPost {
|
||||
@@ -63,8 +68,11 @@ interface ParsedPost {
|
||||
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
|
||||
showFooter?: boolean; // Show footer on this post (overrides siteConfig default)
|
||||
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
|
||||
showSocialFooter?: boolean; // Show social footer on this post (overrides siteConfig default)
|
||||
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
|
||||
blogFeatured?: boolean; // Show as hero featured post on /blog page
|
||||
newsletter?: boolean; // Override newsletter signup display (true/false)
|
||||
contactForm?: boolean; // Enable contact form on this post
|
||||
}
|
||||
|
||||
// Page frontmatter (for static pages like About, Projects, Contact)
|
||||
@@ -84,7 +92,11 @@ interface PageFrontmatter {
|
||||
layout?: string; // Layout type: "sidebar" for docs-style layout
|
||||
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
|
||||
showFooter?: boolean; // Show footer on this page (overrides siteConfig default)
|
||||
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
|
||||
showSocialFooter?: boolean; // Show social footer on this page (overrides siteConfig default)
|
||||
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
|
||||
contactForm?: boolean; // Enable contact form on this page
|
||||
newsletter?: boolean; // Override newsletter signup display (true/false)
|
||||
}
|
||||
|
||||
interface ParsedPage {
|
||||
@@ -104,7 +116,11 @@ interface ParsedPage {
|
||||
layout?: string; // Layout type: "sidebar" for docs-style layout
|
||||
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
|
||||
showFooter?: boolean; // Show footer on this page (overrides siteConfig default)
|
||||
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
|
||||
showSocialFooter?: boolean; // Show social footer on this page (overrides siteConfig default)
|
||||
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
|
||||
contactForm?: boolean; // Enable contact form on this page
|
||||
newsletter?: boolean; // Override newsletter signup display (true/false)
|
||||
}
|
||||
|
||||
// Calculate reading time based on word count
|
||||
@@ -149,8 +165,11 @@ function parseMarkdownFile(filePath: string): ParsedPost | null {
|
||||
rightSidebar: frontmatter.rightSidebar, // Enable right sidebar with CopyPageDropdown
|
||||
showFooter: frontmatter.showFooter, // Show footer on this post
|
||||
footer: frontmatter.footer, // Footer markdown content
|
||||
showSocialFooter: frontmatter.showSocialFooter, // Show social footer on this post
|
||||
aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar
|
||||
blogFeatured: frontmatter.blogFeatured, // Show as hero featured post on /blog page
|
||||
newsletter: frontmatter.newsletter, // Override newsletter signup display
|
||||
contactForm: frontmatter.contactForm, // Enable contact form on this post
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error parsing ${filePath}:`, error);
|
||||
@@ -205,7 +224,11 @@ function parsePageFile(filePath: string): ParsedPage | null {
|
||||
layout: frontmatter.layout, // Layout type: "sidebar" for docs-style layout
|
||||
rightSidebar: frontmatter.rightSidebar, // Enable right sidebar with CopyPageDropdown
|
||||
showFooter: frontmatter.showFooter, // Show footer on this page
|
||||
footer: frontmatter.footer, // Footer markdown content
|
||||
showSocialFooter: frontmatter.showSocialFooter, // Show social footer on this page
|
||||
aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar
|
||||
contactForm: frontmatter.contactForm, // Enable contact form on this page
|
||||
newsletter: frontmatter.newsletter, // Override newsletter signup display
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error parsing page ${filePath}:`, error);
|
||||
|
||||
@@ -5,6 +5,8 @@ import Stats from "./pages/Stats";
|
||||
import Blog from "./pages/Blog";
|
||||
import Write from "./pages/Write";
|
||||
import TagPage from "./pages/TagPage";
|
||||
import Unsubscribe from "./pages/Unsubscribe";
|
||||
import NewsletterAdmin from "./pages/NewsletterAdmin";
|
||||
import Layout from "./components/Layout";
|
||||
import { usePageTracking } from "./hooks/usePageTracking";
|
||||
import { SidebarProvider } from "./context/SidebarContext";
|
||||
@@ -20,6 +22,11 @@ function App() {
|
||||
return <Write />;
|
||||
}
|
||||
|
||||
// Newsletter admin page renders without Layout (full-screen admin)
|
||||
if (location.pathname === "/newsletter-admin") {
|
||||
return <NewsletterAdmin />;
|
||||
}
|
||||
|
||||
// Determine if we should use a custom homepage
|
||||
const useCustomHomepage =
|
||||
siteConfig.homepage.type !== "default" && siteConfig.homepage.slug;
|
||||
@@ -55,6 +62,8 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
{/* Unsubscribe route for newsletter */}
|
||||
<Route path="/unsubscribe" element={<Unsubscribe />} />
|
||||
{/* Blog page route - only enabled when blogPage.enabled is true */}
|
||||
{siteConfig.blogPage.enabled && (
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
|
||||
@@ -7,6 +7,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import NewsletterSignup from "./NewsletterSignup";
|
||||
import ContactForm from "./ContactForm";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
// Sanitize schema that allows collapsible sections (details/summary)
|
||||
const sanitizeSchema = {
|
||||
@@ -261,6 +264,61 @@ const cursorTanTheme: { [key: string]: React.CSSProperties } = {
|
||||
|
||||
interface BlogPostProps {
|
||||
content: string;
|
||||
slug?: string; // For tracking source of newsletter/contact form signups
|
||||
pageType?: "post" | "page"; // Type of content (for tracking)
|
||||
}
|
||||
|
||||
// Content segment types for inline embeds
|
||||
type ContentSegment =
|
||||
| { type: "content"; value: string }
|
||||
| { type: "newsletter" }
|
||||
| { type: "contactform" };
|
||||
|
||||
// Parse content for inline embed placeholders
|
||||
// Supports: <!-- newsletter --> and <!-- contactform -->
|
||||
function parseContentForEmbeds(content: string): ContentSegment[] {
|
||||
const segments: ContentSegment[] = [];
|
||||
|
||||
// Pattern matches <!-- newsletter --> or <!-- contactform --> (case insensitive)
|
||||
const pattern = /<!--\s*(newsletter|contactform)\s*-->/gi;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
// Add content before the placeholder
|
||||
if (match.index > lastIndex) {
|
||||
const textBefore = content.slice(lastIndex, match.index);
|
||||
if (textBefore.trim()) {
|
||||
segments.push({ type: "content", value: textBefore });
|
||||
}
|
||||
}
|
||||
|
||||
// Add the embed placeholder
|
||||
const embedType = match[1].toLowerCase();
|
||||
if (embedType === "newsletter") {
|
||||
segments.push({ type: "newsletter" });
|
||||
} else if (embedType === "contactform") {
|
||||
segments.push({ type: "contactform" });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining content after last placeholder
|
||||
if (lastIndex < content.length) {
|
||||
const remaining = content.slice(lastIndex);
|
||||
if (remaining.trim()) {
|
||||
segments.push({ type: "content", value: remaining });
|
||||
}
|
||||
}
|
||||
|
||||
// If no placeholders found, return single content segment
|
||||
if (segments.length === 0) {
|
||||
segments.push({ type: "content", value: content });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Generate slug from heading text for anchor links
|
||||
@@ -308,7 +366,7 @@ function HeadingAnchor({ id }: { id: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlogPost({ content }: BlogPostProps) {
|
||||
export default function BlogPost({ content, slug, pageType = "post" }: BlogPostProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const getCodeTheme = () => {
|
||||
@@ -324,6 +382,233 @@ export default function BlogPost({ content }: BlogPostProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Parse content for inline embeds
|
||||
const segments = parseContentForEmbeds(content);
|
||||
const hasInlineEmbeds = segments.some((s) => s.type !== "content");
|
||||
|
||||
// Helper to render a single markdown segment
|
||||
const renderMarkdown = (markdownContent: string, key?: number) => (
|
||||
<ReactMarkdown
|
||||
key={key}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={{
|
||||
code(codeProps) {
|
||||
const { className, children, node, style, ...restProps } = codeProps as {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
node?: { tagName?: string; properties?: { className?: string[] } };
|
||||
style?: React.CSSProperties;
|
||||
inline?: boolean;
|
||||
};
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
|
||||
// Detect inline code: no language class AND content is short without newlines
|
||||
const codeContent = String(children);
|
||||
const hasNewlines = codeContent.includes('\n');
|
||||
const isShort = codeContent.length < 80;
|
||||
const hasLanguage = !!match || !!className;
|
||||
|
||||
// It's inline only if: no language, short content, no newlines
|
||||
const isInline = !hasLanguage && isShort && !hasNewlines;
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="inline-code" style={style} {...restProps}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const codeString = String(children).replace(/\n$/, "");
|
||||
const language = match ? match[1] : "text";
|
||||
const isTextBlock = language === "text";
|
||||
|
||||
// Custom styles for text blocks to enable wrapping
|
||||
const textBlockStyle = isTextBlock ? {
|
||||
whiteSpace: "pre-wrap" as const,
|
||||
wordWrap: "break-word" as const,
|
||||
overflowWrap: "break-word" as const,
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<div className={`code-block-wrapper ${isTextBlock ? "code-block-text" : ""}`}>
|
||||
{match && <span className="code-language">{match[1]}</span>}
|
||||
<CodeCopyButton code={codeString} />
|
||||
<SyntaxHighlighter
|
||||
style={getCodeTheme()}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
customStyle={textBlockStyle}
|
||||
codeTagProps={isTextBlock ? { style: textBlockStyle } : undefined}
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
img({ src, alt }) {
|
||||
return (
|
||||
<span className="blog-image-wrapper">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ""}
|
||||
className="blog-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
{alt && <span className="blog-image-caption">{alt}</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
a({ href, children }) {
|
||||
const isExternal = href?.startsWith("http");
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
className="blog-link"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="blog-blockquote">{children}</blockquote>
|
||||
);
|
||||
},
|
||||
h1({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h1 id={id} className="blog-h1">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h2 id={id} className="blog-h2">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h3 id={id} className="blog-h3">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
h4({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h4 id={id} className="blog-h4">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
},
|
||||
h5({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h5 id={id} className="blog-h5">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
},
|
||||
h6({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h6 id={id} className="blog-h6">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h6>
|
||||
);
|
||||
},
|
||||
ul({ children }) {
|
||||
return <ul className="blog-ul">{children}</ul>;
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ol className="blog-ol">{children}</ol>;
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="blog-li">{children}</li>;
|
||||
},
|
||||
hr() {
|
||||
return <hr className="blog-hr" />;
|
||||
},
|
||||
// Table components for GitHub-style tables
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="blog-table-wrapper">
|
||||
<table className="blog-table">{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="blog-thead">{children}</thead>;
|
||||
},
|
||||
tbody({ children }) {
|
||||
return <tbody className="blog-tbody">{children}</tbody>;
|
||||
},
|
||||
tr({ children }) {
|
||||
return <tr className="blog-tr">{children}</tr>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="blog-th">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="blog-td">{children}</td>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{markdownContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
|
||||
// Build source string for tracking
|
||||
const sourcePrefix = pageType === "page" ? "page" : "post";
|
||||
const source = slug ? `${sourcePrefix}:${slug}` : sourcePrefix;
|
||||
|
||||
// Render with inline embeds if placeholders exist
|
||||
if (hasInlineEmbeds) {
|
||||
return (
|
||||
<article className="blog-post-content">
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "newsletter") {
|
||||
// Newsletter signup inline
|
||||
return siteConfig.newsletter?.enabled ? (
|
||||
<NewsletterSignup
|
||||
key={`newsletter-${index}`}
|
||||
source={pageType === "page" ? "post" : "post"}
|
||||
postSlug={slug}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
if (segment.type === "contactform") {
|
||||
// Contact form inline
|
||||
return siteConfig.contactForm?.enabled ? (
|
||||
<ContactForm
|
||||
key={`contactform-${index}`}
|
||||
source={source}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
// Markdown content segment
|
||||
return renderMarkdown(segment.value, index);
|
||||
})}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// No inline embeds, render content normally
|
||||
return (
|
||||
<article className="blog-post-content">
|
||||
<ReactMarkdown
|
||||
|
||||
167
src/components/ContactForm.tsx
Normal file
167
src/components/ContactForm.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
// Props for the ContactForm component
|
||||
interface ContactFormProps {
|
||||
source: string; // "page:slug" or "post:slug"
|
||||
title?: string; // Optional title override
|
||||
description?: string; // Optional description override
|
||||
}
|
||||
|
||||
// Contact form component
|
||||
// Displays a form with name, email, and message fields
|
||||
// Submits to Convex which sends email via AgentMail
|
||||
export default function ContactForm({
|
||||
source,
|
||||
title,
|
||||
description,
|
||||
}: ContactFormProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
|
||||
const submitContact = useMutation(api.contact.submitContact);
|
||||
|
||||
// Check if contact form is enabled globally
|
||||
if (!siteConfig.contactForm?.enabled) return null;
|
||||
|
||||
// Use provided title/description or fall back to config defaults
|
||||
const displayTitle = title || siteConfig.contactForm.title;
|
||||
const displayDescription = description || siteConfig.contactForm.description;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Basic validation
|
||||
if (!name.trim()) {
|
||||
setStatus("error");
|
||||
setStatusMessage("Please enter your name.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.trim()) {
|
||||
setStatus("error");
|
||||
setStatusMessage("Please enter your email.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.trim()) {
|
||||
setStatus("error");
|
||||
setStatusMessage("Please enter a message.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
|
||||
try {
|
||||
const result = await submitContact({
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
message: message.trim(),
|
||||
source,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setStatus("success");
|
||||
setStatusMessage(result.message);
|
||||
// Clear form on success
|
||||
setName("");
|
||||
setEmail("");
|
||||
setMessage("");
|
||||
} else {
|
||||
setStatus("error");
|
||||
setStatusMessage(result.message);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setStatusMessage("Something went wrong. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="contact-form">
|
||||
<div className="contact-form__content">
|
||||
<h3 className="contact-form__title">{displayTitle}</h3>
|
||||
{displayDescription && (
|
||||
<p className="contact-form__description">{displayDescription}</p>
|
||||
)}
|
||||
|
||||
{status === "success" ? (
|
||||
<div className="contact-form__success">
|
||||
<p>{statusMessage}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="contact-form__reset-button"
|
||||
onClick={() => setStatus("idle")}
|
||||
>
|
||||
Send another message
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="contact-form__form">
|
||||
<div className="contact-form__field">
|
||||
<label htmlFor="contact-name" className="contact-form__label">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="contact-form__input"
|
||||
disabled={status === "loading"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="contact-form__field">
|
||||
<label htmlFor="contact-email" className="contact-form__label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="contact-form__input"
|
||||
disabled={status === "loading"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="contact-form__field">
|
||||
<label htmlFor="contact-message" className="contact-form__label">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Your message..."
|
||||
className="contact-form__textarea"
|
||||
rows={5}
|
||||
disabled={status === "loading"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="contact-form__button"
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? "Sending..." : "Send Message"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<p className="contact-form__error">{statusMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
116
src/components/NewsletterSignup.tsx
Normal file
116
src/components/NewsletterSignup.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
// Props for the newsletter signup component
|
||||
interface NewsletterSignupProps {
|
||||
source: "home" | "blog-page" | "post"; // Where the signup form appears
|
||||
postSlug?: string; // For tracking which post they subscribed from
|
||||
title?: string; // Override default title
|
||||
description?: string; // Override default description
|
||||
}
|
||||
|
||||
// Newsletter signup component
|
||||
// Displays email input form for newsletter subscriptions
|
||||
// Integrates with Convex backend for subscriber management
|
||||
export default function NewsletterSignup({
|
||||
source,
|
||||
postSlug,
|
||||
title,
|
||||
description,
|
||||
}: NewsletterSignupProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "loading" | "success" | "error"
|
||||
>("idle");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const subscribe = useMutation(api.newsletter.subscribe);
|
||||
|
||||
// Check if newsletter is enabled globally
|
||||
if (!siteConfig.newsletter?.enabled) return null;
|
||||
|
||||
// Get config for this placement
|
||||
const config =
|
||||
source === "home"
|
||||
? siteConfig.newsletter.signup.home
|
||||
: source === "blog-page"
|
||||
? siteConfig.newsletter.signup.blogPage
|
||||
: siteConfig.newsletter.signup.posts;
|
||||
|
||||
// Check if this specific placement is enabled
|
||||
if (!config.enabled) return null;
|
||||
|
||||
const displayTitle = title || config.title;
|
||||
const displayDescription = description || config.description;
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
setStatus("error");
|
||||
setMessage("Please enter your email.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
|
||||
try {
|
||||
// Include post slug in source for tracking
|
||||
const sourceValue = postSlug ? `post:${postSlug}` : source;
|
||||
const result = await subscribe({ email, source: sourceValue });
|
||||
|
||||
if (result.success) {
|
||||
setStatus("success");
|
||||
setMessage(result.message);
|
||||
setEmail("");
|
||||
} else {
|
||||
setStatus("error");
|
||||
setMessage(result.message);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setMessage("Something went wrong. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="newsletter-signup">
|
||||
<div className="newsletter-signup__content">
|
||||
<h3 className="newsletter-signup__title">{displayTitle}</h3>
|
||||
{displayDescription && (
|
||||
<p className="newsletter-signup__description">{displayDescription}</p>
|
||||
)}
|
||||
|
||||
{status === "success" ? (
|
||||
<p className="newsletter-signup__success">{message}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="newsletter-signup__form">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="newsletter-signup__input"
|
||||
disabled={status === "loading"}
|
||||
aria-label="Email address"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="newsletter-signup__button"
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? "..." : "Subscribe"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<p className="newsletter-signup__error">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import AIChatView from "./AIChatView";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
interface RightSidebarProps {
|
||||
aiChatEnabled?: boolean; // From frontmatter aiChat: true
|
||||
aiChatEnabled?: boolean; // From frontmatter aiChat: true/false (undefined = not set)
|
||||
pageContent?: string; // Page markdown content for AI context
|
||||
slug?: string; // Page/post slug for chat context ID
|
||||
}
|
||||
@@ -15,9 +15,15 @@ export default function RightSidebar({
|
||||
slug,
|
||||
}: RightSidebarProps) {
|
||||
// Check if AI chat should be shown
|
||||
// Requires both siteConfig.aiChat.enabledOnContent AND frontmatter aiChat: true
|
||||
// Requires:
|
||||
// 1. Global config enabled (siteConfig.aiChat.enabledOnContent)
|
||||
// 2. Frontmatter explicitly enabled (aiChat: true)
|
||||
// 3. Slug exists for context ID
|
||||
// If aiChat: false is set in frontmatter, chat will be hidden even if global config is enabled
|
||||
const showAIChat =
|
||||
siteConfig.aiChat.enabledOnContent && aiChatEnabled && slug;
|
||||
siteConfig.aiChat.enabledOnContent &&
|
||||
aiChatEnabled === true &&
|
||||
slug;
|
||||
|
||||
if (showAIChat) {
|
||||
return (
|
||||
|
||||
75
src/components/SocialFooter.tsx
Normal file
75
src/components/SocialFooter.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import siteConfig from "../config/siteConfig";
|
||||
import type { SocialLink } from "../config/siteConfig";
|
||||
import {
|
||||
GithubLogo,
|
||||
TwitterLogo,
|
||||
LinkedinLogo,
|
||||
InstagramLogo,
|
||||
YoutubeLogo,
|
||||
TiktokLogo,
|
||||
DiscordLogo,
|
||||
Globe,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
// Map platform names to Phosphor icons
|
||||
const platformIcons: Record<SocialLink["platform"], React.ComponentType<{ size?: number; weight?: "regular" | "bold" | "fill" }>> = {
|
||||
github: GithubLogo,
|
||||
twitter: TwitterLogo,
|
||||
linkedin: LinkedinLogo,
|
||||
instagram: InstagramLogo,
|
||||
youtube: YoutubeLogo,
|
||||
tiktok: TiktokLogo,
|
||||
discord: DiscordLogo,
|
||||
website: Globe,
|
||||
};
|
||||
|
||||
// Social footer component
|
||||
// Displays social icons on left and copyright on right
|
||||
// Visibility controlled by siteConfig.socialFooter settings and frontmatter showSocialFooter field
|
||||
export default function SocialFooter() {
|
||||
const { socialFooter } = siteConfig;
|
||||
|
||||
// Don't render if social footer is globally disabled
|
||||
if (!socialFooter?.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get current year for copyright
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<section className="social-footer">
|
||||
<div className="social-footer-content">
|
||||
{/* Social links on the left */}
|
||||
<div className="social-footer-links">
|
||||
{socialFooter.socialLinks.map((link) => {
|
||||
const IconComponent = platformIcons[link.platform];
|
||||
return (
|
||||
<a
|
||||
key={link.platform}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="social-footer-link"
|
||||
aria-label={`Follow on ${link.platform}`}
|
||||
>
|
||||
<IconComponent size={20} weight="regular" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Copyright on the right */}
|
||||
<div className="social-footer-copyright">
|
||||
<span className="social-footer-copyright-symbol">©</span>
|
||||
<span className="social-footer-copyright-name">
|
||||
{socialFooter.copyright.siteName}
|
||||
</span>
|
||||
{socialFooter.copyright.showYear && (
|
||||
<span className="social-footer-copyright-year">{currentYear}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -114,6 +114,93 @@ export interface AIChatConfig {
|
||||
enabledOnContent: boolean; // Allow AI chat on posts/pages via frontmatter aiChat: true
|
||||
}
|
||||
|
||||
// Newsletter signup placement configuration
|
||||
// Controls where signup forms appear on the site
|
||||
export interface NewsletterSignupPlacement {
|
||||
enabled: boolean; // Show signup form at this location
|
||||
position: "above-footer" | "below-intro" | "below-content" | "below-posts";
|
||||
title: string; // Form heading
|
||||
description: string; // Form description text
|
||||
}
|
||||
|
||||
// Newsletter configuration (email-only signup)
|
||||
// Integrates with AgentMail for email collection and sending
|
||||
// Inbox configured via AGENTMAIL_INBOX environment variable in Convex dashboard
|
||||
export interface NewsletterConfig {
|
||||
enabled: boolean; // Master switch for newsletter feature
|
||||
|
||||
// Signup form placements
|
||||
signup: {
|
||||
home: NewsletterSignupPlacement; // Homepage signup
|
||||
blogPage: NewsletterSignupPlacement; // Blog page (/blog) signup
|
||||
posts: NewsletterSignupPlacement; // Individual blog posts (can override via frontmatter)
|
||||
};
|
||||
}
|
||||
|
||||
// Contact form configuration
|
||||
// Enables contact forms on pages/posts via frontmatter contactForm: true
|
||||
// Recipient email configured via AGENTMAIL_CONTACT_EMAIL env var (falls back to AGENTMAIL_INBOX)
|
||||
export interface ContactFormConfig {
|
||||
enabled: boolean; // Global toggle for contact form feature
|
||||
title: string; // Default form title
|
||||
description: string; // Default form description
|
||||
}
|
||||
|
||||
// Newsletter admin configuration
|
||||
// Provides admin UI for managing subscribers and sending newsletters
|
||||
// Access at /newsletter-admin route
|
||||
export interface NewsletterAdminConfig {
|
||||
enabled: boolean; // Global toggle for admin UI
|
||||
showInNav: boolean; // Show link in navigation (hidden by default for security)
|
||||
}
|
||||
|
||||
// Newsletter notifications configuration
|
||||
// Sends developer notifications for subscriber events
|
||||
// Uses AGENTMAIL_CONTACT_EMAIL or AGENTMAIL_INBOX as recipient
|
||||
export interface NewsletterNotificationsConfig {
|
||||
enabled: boolean; // Global toggle for notifications
|
||||
newSubscriberAlert: boolean; // Send email when new subscriber signs up
|
||||
weeklyStatsSummary: boolean; // Send weekly stats summary email
|
||||
}
|
||||
|
||||
// Weekly digest configuration
|
||||
// Automated weekly email with posts from the past 7 days
|
||||
export interface WeeklyDigestConfig {
|
||||
enabled: boolean; // Global toggle for weekly digest
|
||||
dayOfWeek: 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0 = Sunday, 6 = Saturday
|
||||
subject: string; // Email subject template
|
||||
}
|
||||
|
||||
// Social link configuration for social footer
|
||||
export interface SocialLink {
|
||||
platform:
|
||||
| "github"
|
||||
| "twitter"
|
||||
| "linkedin"
|
||||
| "instagram"
|
||||
| "youtube"
|
||||
| "tiktok"
|
||||
| "discord"
|
||||
| "website";
|
||||
url: string; // Full URL (e.g., "https://github.com/username")
|
||||
}
|
||||
|
||||
// Social footer configuration
|
||||
// Displays social icons on left and copyright on right
|
||||
// Appears below the main footer on homepage, blog posts, and pages
|
||||
export interface SocialFooterConfig {
|
||||
enabled: boolean; // Global toggle for social footer
|
||||
showOnHomepage: boolean; // Show social footer on homepage
|
||||
showOnPosts: boolean; // Default: show social footer on blog posts
|
||||
showOnPages: boolean; // Default: show social footer on static pages
|
||||
showOnBlogPage: boolean; // Show social footer on /blog page
|
||||
socialLinks: SocialLink[]; // Array of social links to display
|
||||
copyright: {
|
||||
siteName: string; // Site name or company name displayed in copyright
|
||||
showYear: boolean; // Show auto-updating year (default: true)
|
||||
};
|
||||
}
|
||||
|
||||
// Site configuration interface
|
||||
export interface SiteConfig {
|
||||
// Basic site info
|
||||
@@ -172,6 +259,24 @@ export interface SiteConfig {
|
||||
|
||||
// AI Chat configuration
|
||||
aiChat: AIChatConfig;
|
||||
|
||||
// Newsletter configuration (optional)
|
||||
newsletter?: NewsletterConfig;
|
||||
|
||||
// Contact form configuration (optional)
|
||||
contactForm?: ContactFormConfig;
|
||||
|
||||
// Social footer configuration (optional)
|
||||
socialFooter?: SocialFooterConfig;
|
||||
|
||||
// Newsletter admin configuration (optional)
|
||||
newsletterAdmin?: NewsletterAdminConfig;
|
||||
|
||||
// Newsletter notifications configuration (optional)
|
||||
newsletterNotifications?: NewsletterNotificationsConfig;
|
||||
|
||||
// Weekly digest configuration (optional)
|
||||
weeklyDigest?: WeeklyDigestConfig;
|
||||
}
|
||||
|
||||
// Default site configuration
|
||||
@@ -226,6 +331,10 @@ export const siteConfig: SiteConfig = {
|
||||
src: "/images/logos/agentmail.svg",
|
||||
href: "https://agentmail.to/utm_source=markdownfast",
|
||||
},
|
||||
{
|
||||
src: "/images/logos/mcp.svg",
|
||||
href: "https://modelcontextprotocol.io/",
|
||||
},
|
||||
],
|
||||
position: "above-footer",
|
||||
speed: 30,
|
||||
@@ -362,6 +471,91 @@ Created by [Wayne](https://x.com/waynesutton) with Convex, Cursor, and Claude Op
|
||||
enabledOnWritePage: true, // Show AI chat toggle on /write page
|
||||
enabledOnContent: true, // Allow AI chat on posts/pages via frontmatter aiChat: true
|
||||
},
|
||||
|
||||
// Newsletter configuration (email-only signup)
|
||||
// Set enabled: true and configure AgentMail to activate
|
||||
// Requires AGENTMAIL_API_KEY and AGENTMAIL_INBOX environment variables in Convex dashboard
|
||||
newsletter: {
|
||||
enabled: true, // Set to true to enable newsletter signup forms
|
||||
signup: {
|
||||
home: {
|
||||
enabled: true,
|
||||
position: "above-footer",
|
||||
title: "Stay Updated",
|
||||
description: "Get new posts delivered to your inbox.",
|
||||
},
|
||||
blogPage: {
|
||||
enabled: true,
|
||||
position: "above-footer",
|
||||
title: "Subscribe",
|
||||
description: "Get notified when new posts are published.",
|
||||
},
|
||||
posts: {
|
||||
enabled: true,
|
||||
position: "below-content",
|
||||
title: "Enjoyed this post?",
|
||||
description: "Subscribe for more updates.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Contact form configuration
|
||||
// Enable via frontmatter contactForm: true on any page or post
|
||||
// Requires AGENTMAIL_API_KEY and AGENTMAIL_INBOX in Convex dashboard
|
||||
// Optionally set AGENTMAIL_CONTACT_EMAIL to override recipient (defaults to AGENTMAIL_INBOX)
|
||||
contactForm: {
|
||||
enabled: true, // Global toggle for contact form feature
|
||||
title: "Get in Touch",
|
||||
description: "Send us a message and we'll get back to you.",
|
||||
},
|
||||
|
||||
// Social footer configuration
|
||||
// Displays social icons on left and copyright on right
|
||||
// Can work with or without the main footer
|
||||
// Use showSocialFooter: false in frontmatter to hide on specific posts/pages
|
||||
socialFooter: {
|
||||
enabled: true, // Global toggle for social footer
|
||||
showOnHomepage: true, // Show social footer on homepage
|
||||
showOnPosts: true, // Default: show social footer on blog posts
|
||||
showOnPages: true, // Default: show social footer on static pages
|
||||
showOnBlogPage: true, // Show social footer on /blog page
|
||||
socialLinks: [
|
||||
{
|
||||
platform: "github",
|
||||
url: "https://github.com/waynesutton/markdown-site",
|
||||
},
|
||||
{ platform: "twitter", url: "https://x.com/waynesutton" },
|
||||
{ platform: "linkedin", url: "https://www.linkedin.com/in/waynesutton/" },
|
||||
],
|
||||
copyright: {
|
||||
siteName: "MarkDown Sync is open-source", // Update with your site/company name
|
||||
showYear: true, // Auto-updates to current year
|
||||
},
|
||||
},
|
||||
|
||||
// Newsletter admin configuration
|
||||
// Admin UI for managing subscribers and sending newsletters at /newsletter-admin
|
||||
// Hidden from nav by default (no auth - security through obscurity)
|
||||
newsletterAdmin: {
|
||||
enabled: true, // Global toggle for admin UI
|
||||
showInNav: false, // Hide from navigation for security
|
||||
},
|
||||
|
||||
// Newsletter notifications configuration
|
||||
// Sends developer notifications for subscriber events via AgentMail
|
||||
newsletterNotifications: {
|
||||
enabled: true, // Global toggle for notifications
|
||||
newSubscriberAlert: true, // Send email when new subscriber signs up
|
||||
weeklyStatsSummary: true, // Send weekly stats summary email
|
||||
},
|
||||
|
||||
// Weekly digest configuration
|
||||
// Automated weekly email with posts from the past 7 days
|
||||
weeklyDigest: {
|
||||
enabled: true, // Global toggle for weekly digest
|
||||
dayOfWeek: 0, // Sunday
|
||||
subject: "Weekly Digest", // Email subject prefix
|
||||
},
|
||||
};
|
||||
|
||||
// Export the config as default for easy importing
|
||||
|
||||
@@ -5,6 +5,8 @@ import { api } from "../../convex/_generated/api";
|
||||
import PostList from "../components/PostList";
|
||||
import BlogHeroCard from "../components/BlogHeroCard";
|
||||
import Footer from "../components/Footer";
|
||||
import SocialFooter from "../components/SocialFooter";
|
||||
import NewsletterSignup from "../components/NewsletterSignup";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
@@ -193,6 +195,13 @@ export default function Blog() {
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Newsletter signup (below-posts position) */}
|
||||
{siteConfig.newsletter?.enabled &&
|
||||
siteConfig.newsletter.signup.blogPage.enabled &&
|
||||
siteConfig.newsletter.signup.blogPage.position === "below-posts" && (
|
||||
<NewsletterSignup source="blog-page" />
|
||||
)}
|
||||
{/* Message when posts are disabled on blog page */}
|
||||
{!showPosts && (
|
||||
<p className="blog-disabled-message">
|
||||
@@ -200,8 +209,21 @@ export default function Blog() {
|
||||
<code>postsDisplay.showOnBlogPage</code> in siteConfig to enable.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Newsletter signup (above-footer position) */}
|
||||
{siteConfig.newsletter?.enabled &&
|
||||
siteConfig.newsletter.signup.blogPage.enabled &&
|
||||
siteConfig.newsletter.signup.blogPage.position === "above-footer" && (
|
||||
<NewsletterSignup source="blog-page" />
|
||||
)}
|
||||
|
||||
{/* Footer section */}
|
||||
{showFooter && <Footer />}
|
||||
|
||||
{/* Social footer section */}
|
||||
{siteConfig.socialFooter?.enabled && siteConfig.socialFooter.showOnBlogPage && (
|
||||
<SocialFooter />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import FeaturedCards from "../components/FeaturedCards";
|
||||
import LogoMarquee from "../components/LogoMarquee";
|
||||
import GitHubContributions from "../components/GitHubContributions";
|
||||
import Footer from "../components/Footer";
|
||||
import SocialFooter from "../components/SocialFooter";
|
||||
import NewsletterSignup from "../components/NewsletterSignup";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
// Local storage key for view mode preference
|
||||
@@ -112,6 +114,13 @@ export default function Home() {
|
||||
|
||||
<p className="home-bio">{siteConfig.bio}</p>
|
||||
|
||||
{/* Newsletter signup (below-intro position) */}
|
||||
{siteConfig.newsletter?.enabled &&
|
||||
siteConfig.newsletter.signup.home.enabled &&
|
||||
siteConfig.newsletter.signup.home.position === "below-intro" && (
|
||||
<NewsletterSignup source="home" />
|
||||
)}
|
||||
|
||||
{/* Featured section with optional view toggle */}
|
||||
{hasFeaturedContent && (
|
||||
<div className="home-featured">
|
||||
@@ -223,10 +232,22 @@ export default function Home() {
|
||||
{/* Logo gallery (above-footer position) */}
|
||||
{renderLogoGallery("above-footer")}
|
||||
|
||||
{/* Newsletter signup (above-footer position) */}
|
||||
{siteConfig.newsletter?.enabled &&
|
||||
siteConfig.newsletter.signup.home.enabled &&
|
||||
siteConfig.newsletter.signup.home.position === "above-footer" && (
|
||||
<NewsletterSignup source="home" />
|
||||
)}
|
||||
|
||||
{/* Footer section */}
|
||||
{siteConfig.footer.enabled && siteConfig.footer.showOnHomepage && (
|
||||
<Footer content={siteConfig.footer.defaultContent} />
|
||||
)}
|
||||
|
||||
{/* Social footer section */}
|
||||
{siteConfig.socialFooter?.enabled && siteConfig.socialFooter.showOnHomepage && (
|
||||
<SocialFooter />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
868
src/pages/NewsletterAdmin.tsx
Normal file
868
src/pages/NewsletterAdmin.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery, useMutation } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import {
|
||||
House,
|
||||
Users,
|
||||
PaperPlaneTilt,
|
||||
Trash,
|
||||
MagnifyingGlass,
|
||||
Funnel,
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
Check,
|
||||
X,
|
||||
Envelope,
|
||||
ChartBar,
|
||||
Copy,
|
||||
PencilSimple,
|
||||
ClockCounterClockwise,
|
||||
TrendUp,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Moon, Sun, Cloud } from "lucide-react";
|
||||
import { Half2Icon } from "@radix-ui/react-icons";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
// Helper to format timestamps
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// Get theme icon based on current theme
|
||||
function getThemeIcon(theme: string) {
|
||||
switch (theme) {
|
||||
case "dark":
|
||||
return <Moon size={18} />;
|
||||
case "light":
|
||||
return <Sun size={18} />;
|
||||
case "tan":
|
||||
return <Half2Icon width={18} height={18} />;
|
||||
case "cloud":
|
||||
return <Cloud size={18} />;
|
||||
default:
|
||||
return <Sun size={18} />;
|
||||
}
|
||||
}
|
||||
|
||||
type FilterType = "all" | "subscribed" | "unsubscribed";
|
||||
type ViewMode =
|
||||
| "subscribers"
|
||||
| "send-post"
|
||||
| "write-email"
|
||||
| "recent-sends"
|
||||
| "email-stats";
|
||||
|
||||
export default function NewsletterAdmin() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
// View mode state - controls what shows in main area
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("subscribers");
|
||||
|
||||
// Subscriber list state
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<FilterType>("all");
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
// Send post state
|
||||
const [selectedPost, setSelectedPost] = useState<string>("");
|
||||
|
||||
// Write email state
|
||||
const [customSubject, setCustomSubject] = useState("");
|
||||
const [customContent, setCustomContent] = useState("");
|
||||
|
||||
// Shared send state
|
||||
const [sendingNewsletter, setSendingNewsletter] = useState(false);
|
||||
const [sendResult, setSendResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
command?: string;
|
||||
} | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Check if admin is enabled
|
||||
if (!siteConfig.newsletterAdmin?.enabled) {
|
||||
return (
|
||||
<div className="newsletter-admin-disabled">
|
||||
<h1>Newsletter Admin</h1>
|
||||
<p>Newsletter admin is disabled in site configuration.</p>
|
||||
<Link to="/">Back to Home</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Queries
|
||||
const subscribersData = useQuery(api.newsletter.getAllSubscribers, {
|
||||
limit: 20,
|
||||
cursor,
|
||||
filter,
|
||||
search: search || undefined,
|
||||
});
|
||||
|
||||
const stats = useQuery(api.newsletter.getNewsletterStats);
|
||||
const postsForNewsletter = useQuery(api.newsletter.getPostsForNewsletter);
|
||||
|
||||
// Mutations
|
||||
const deleteSubscriber = useMutation(api.newsletter.deleteSubscriber);
|
||||
const scheduleSendPost = useMutation(
|
||||
api.newsletter.scheduleSendPostNewsletter,
|
||||
);
|
||||
const scheduleSendCustom = useMutation(
|
||||
api.newsletter.scheduleSendCustomNewsletter,
|
||||
);
|
||||
|
||||
// Handle view mode change
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
setSendResult(null); // Clear results when switching views
|
||||
}, []);
|
||||
|
||||
// Handle search with debounce
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
setCursor(undefined); // Reset pagination on search
|
||||
}, []);
|
||||
|
||||
// Handle filter change
|
||||
const handleFilterChange = useCallback((newFilter: FilterType) => {
|
||||
setFilter(newFilter);
|
||||
setCursor(undefined); // Reset pagination on filter change
|
||||
}, []);
|
||||
|
||||
// Handle delete subscriber
|
||||
const handleDelete = useCallback(
|
||||
async (subscriberId: Id<"newsletterSubscribers">) => {
|
||||
const result = await deleteSubscriber({ subscriberId });
|
||||
if (result.success) {
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
},
|
||||
[deleteSubscriber],
|
||||
);
|
||||
|
||||
// Handle send post newsletter
|
||||
const handleSendPostNewsletter = useCallback(async () => {
|
||||
if (!selectedPost) return;
|
||||
|
||||
setSendingNewsletter(true);
|
||||
setSendResult(null);
|
||||
setCopied(false);
|
||||
|
||||
try {
|
||||
const result = await scheduleSendPost({
|
||||
postSlug: selectedPost,
|
||||
siteUrl: window.location.origin,
|
||||
siteName: siteConfig.name,
|
||||
});
|
||||
|
||||
const command = `npm run newsletter:send ${selectedPost}`;
|
||||
setSendResult({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
command: result.success ? command : undefined,
|
||||
});
|
||||
} catch {
|
||||
setSendResult({
|
||||
success: false,
|
||||
message: "Failed to send newsletter. Check console for details.",
|
||||
});
|
||||
} finally {
|
||||
setSendingNewsletter(false);
|
||||
}
|
||||
}, [selectedPost, scheduleSendPost]);
|
||||
|
||||
// Handle send custom newsletter
|
||||
const handleSendCustomNewsletter = useCallback(async () => {
|
||||
if (!customSubject.trim() || !customContent.trim()) return;
|
||||
|
||||
setSendingNewsletter(true);
|
||||
setSendResult(null);
|
||||
setCopied(false);
|
||||
|
||||
try {
|
||||
const result = await scheduleSendCustom({
|
||||
subject: customSubject,
|
||||
content: customContent,
|
||||
siteUrl: window.location.origin,
|
||||
siteName: siteConfig.name,
|
||||
});
|
||||
|
||||
setSendResult({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
});
|
||||
|
||||
// Clear form on success
|
||||
if (result.success) {
|
||||
setCustomSubject("");
|
||||
setCustomContent("");
|
||||
}
|
||||
} catch {
|
||||
setSendResult({
|
||||
success: false,
|
||||
message: "Failed to send newsletter. Check console for details.",
|
||||
});
|
||||
} finally {
|
||||
setSendingNewsletter(false);
|
||||
}
|
||||
}, [customSubject, customContent, scheduleSendCustom]);
|
||||
|
||||
// Handle copy command to clipboard
|
||||
const handleCopyCommand = useCallback(async (command: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = command;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Pagination handlers
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (subscribersData?.nextCursor) {
|
||||
setCursor(subscribersData.nextCursor);
|
||||
}
|
||||
}, [subscribersData?.nextCursor]);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
setCursor(undefined); // Reset to first page
|
||||
}, []);
|
||||
|
||||
// Get main area title based on view mode
|
||||
const getMainTitle = () => {
|
||||
switch (viewMode) {
|
||||
case "subscribers":
|
||||
return "Subscribers";
|
||||
case "send-post":
|
||||
return "Send Post";
|
||||
case "write-email":
|
||||
return "Write Email";
|
||||
case "recent-sends":
|
||||
return "Recent Sends";
|
||||
case "email-stats":
|
||||
return "Email Stats";
|
||||
default:
|
||||
return "Subscribers";
|
||||
}
|
||||
};
|
||||
|
||||
// Render main content based on view mode
|
||||
const renderMainContent = () => {
|
||||
switch (viewMode) {
|
||||
case "subscribers":
|
||||
return renderSubscriberList();
|
||||
case "send-post":
|
||||
return renderSendPost();
|
||||
case "write-email":
|
||||
return renderWriteEmail();
|
||||
case "recent-sends":
|
||||
return renderRecentSends();
|
||||
case "email-stats":
|
||||
return renderEmailStats();
|
||||
default:
|
||||
return renderSubscriberList();
|
||||
}
|
||||
};
|
||||
|
||||
// Render subscriber list
|
||||
const renderSubscriberList = () => (
|
||||
<>
|
||||
{/* Filter pills */}
|
||||
<div className="newsletter-admin-filter-bar">
|
||||
<Funnel size={16} />
|
||||
<span className="newsletter-admin-filter-label">
|
||||
{subscribersData?.totalCount ?? 0} results
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subscriber list */}
|
||||
<div className="newsletter-admin-list">
|
||||
{!subscribersData ? (
|
||||
<div className="newsletter-admin-loading">Loading subscribers...</div>
|
||||
) : subscribersData.subscribers.length === 0 ? (
|
||||
<div className="newsletter-admin-empty">
|
||||
{search
|
||||
? "No subscribers match your search."
|
||||
: "No subscribers yet."}
|
||||
</div>
|
||||
) : (
|
||||
subscribersData.subscribers.map((subscriber) => (
|
||||
<div
|
||||
key={subscriber._id}
|
||||
className={`newsletter-admin-subscriber ${!subscriber.subscribed ? "unsubscribed" : ""}`}
|
||||
>
|
||||
<div className="newsletter-admin-subscriber-info">
|
||||
<span className="newsletter-admin-subscriber-email">
|
||||
{subscriber.email}
|
||||
</span>
|
||||
<span className="newsletter-admin-subscriber-meta">
|
||||
{subscriber.subscribed ? (
|
||||
<span className="newsletter-admin-badge active">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="newsletter-admin-badge inactive">
|
||||
Unsubscribed
|
||||
</span>
|
||||
)}
|
||||
<span className="newsletter-admin-subscriber-source">
|
||||
via {subscriber.source}
|
||||
</span>
|
||||
<span className="newsletter-admin-subscriber-date">
|
||||
{formatDate(subscriber.subscribedAt)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="newsletter-admin-subscriber-actions">
|
||||
{deleteConfirm === subscriber._id ? (
|
||||
<div className="newsletter-admin-delete-confirm">
|
||||
<span>Delete?</span>
|
||||
<button
|
||||
onClick={() => handleDelete(subscriber._id)}
|
||||
className="newsletter-admin-delete-yes"
|
||||
title="Confirm delete"
|
||||
>
|
||||
<Check size={16} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="newsletter-admin-delete-no"
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(subscriber._id)}
|
||||
className="newsletter-admin-action-btn delete"
|
||||
title="Delete subscriber"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{subscribersData && subscribersData.subscribers.length > 0 && (
|
||||
<div className="newsletter-admin-pagination">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={!cursor}
|
||||
className="newsletter-admin-pagination-btn"
|
||||
>
|
||||
<CaretLeft size={16} />
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!subscribersData.nextCursor}
|
||||
className="newsletter-admin-pagination-btn"
|
||||
>
|
||||
Next
|
||||
<CaretRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Render send post form
|
||||
const renderSendPost = () => (
|
||||
<div className="newsletter-admin-form-container full-width">
|
||||
<p className="newsletter-admin-form-desc">
|
||||
Select a blog post to send as a newsletter to all active subscribers.
|
||||
</p>
|
||||
|
||||
<div className="newsletter-admin-form-group">
|
||||
<label className="newsletter-admin-label">Select Post</label>
|
||||
<select
|
||||
value={selectedPost}
|
||||
onChange={(e) => setSelectedPost(e.target.value)}
|
||||
className="newsletter-admin-select"
|
||||
>
|
||||
<option value="">Choose a post...</option>
|
||||
{postsForNewsletter?.map((post) => (
|
||||
<option key={post.slug} value={post.slug} disabled={post.wasSent}>
|
||||
{post.title} ({post.date}){post.wasSent ? " - SENT" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSendPostNewsletter}
|
||||
disabled={!selectedPost || sendingNewsletter}
|
||||
className="newsletter-admin-send-btn"
|
||||
>
|
||||
{sendingNewsletter ? (
|
||||
"Sending..."
|
||||
) : (
|
||||
<>
|
||||
<PaperPlaneTilt size={16} />
|
||||
Send to Subscribers
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{renderSendResult()}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render write email form
|
||||
const renderWriteEmail = () => (
|
||||
<div className="newsletter-admin-form-container full-width">
|
||||
<p className="newsletter-admin-form-desc">
|
||||
Write a custom email to send to all active subscribers. Supports
|
||||
markdown formatting.
|
||||
</p>
|
||||
|
||||
<div className="newsletter-admin-form-group">
|
||||
<label className="newsletter-admin-label">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customSubject}
|
||||
onChange={(e) => setCustomSubject(e.target.value)}
|
||||
placeholder="Email subject line..."
|
||||
className="newsletter-admin-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="newsletter-admin-form-group">
|
||||
<label className="newsletter-admin-label">Content (Markdown)</label>
|
||||
<textarea
|
||||
value={customContent}
|
||||
onChange={(e) => setCustomContent(e.target.value)}
|
||||
placeholder="Write your email content here...
|
||||
|
||||
Supports markdown:
|
||||
# Heading
|
||||
**bold** and *italic*
|
||||
[link text](url)
|
||||
- list items"
|
||||
className="newsletter-admin-textarea"
|
||||
rows={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSendCustomNewsletter}
|
||||
disabled={
|
||||
!customSubject.trim() || !customContent.trim() || sendingNewsletter
|
||||
}
|
||||
className="newsletter-admin-send-btn"
|
||||
>
|
||||
{sendingNewsletter ? (
|
||||
"Sending..."
|
||||
) : (
|
||||
<>
|
||||
<PaperPlaneTilt size={16} />
|
||||
Send to Subscribers
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{renderSendResult()}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render recent sends (shows both post and custom emails)
|
||||
const renderRecentSends = () => (
|
||||
<div className="newsletter-admin-recent-container full-width">
|
||||
{!stats ? (
|
||||
<div className="newsletter-admin-loading">Loading recent sends...</div>
|
||||
) : stats.recentNewsletters.length === 0 ? (
|
||||
<div className="newsletter-admin-empty">No newsletters sent yet.</div>
|
||||
) : (
|
||||
<div className="newsletter-admin-recent-list-main">
|
||||
{stats.recentNewsletters.map((newsletter, index) => (
|
||||
<div
|
||||
key={`${newsletter.postSlug}-${index}`}
|
||||
className="newsletter-admin-recent-item-main"
|
||||
>
|
||||
<div className="newsletter-admin-recent-info">
|
||||
<span className="newsletter-admin-recent-slug-main">
|
||||
{newsletter.type === "custom"
|
||||
? newsletter.subject || "Custom Email"
|
||||
: newsletter.postSlug}
|
||||
</span>
|
||||
<span className="newsletter-admin-recent-meta-main">
|
||||
{newsletter.type === "custom" ? (
|
||||
<span className="newsletter-admin-badge-type custom">
|
||||
Custom
|
||||
</span>
|
||||
) : (
|
||||
<span className="newsletter-admin-badge-type post">
|
||||
Post
|
||||
</span>
|
||||
)}
|
||||
Sent to {newsletter.sentCount} subscriber
|
||||
{newsletter.sentCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<span className="newsletter-admin-recent-date">
|
||||
{formatDateTime(newsletter.sentAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render email stats
|
||||
const renderEmailStats = () => (
|
||||
<div className="newsletter-admin-email-stats full-width">
|
||||
{!stats ? (
|
||||
<div className="newsletter-admin-loading">Loading stats...</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stats cards */}
|
||||
<div className="newsletter-admin-stats-cards">
|
||||
<div className="newsletter-admin-stat-card">
|
||||
<div className="newsletter-admin-stat-card-icon">
|
||||
<PaperPlaneTilt size={24} />
|
||||
</div>
|
||||
<div className="newsletter-admin-stat-card-content">
|
||||
<span className="newsletter-admin-stat-card-value">
|
||||
{stats.totalEmailsSent}
|
||||
</span>
|
||||
<span className="newsletter-admin-stat-card-label">
|
||||
Total Emails Sent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="newsletter-admin-stat-card">
|
||||
<div className="newsletter-admin-stat-card-icon">
|
||||
<Envelope size={24} />
|
||||
</div>
|
||||
<div className="newsletter-admin-stat-card-content">
|
||||
<span className="newsletter-admin-stat-card-value">
|
||||
{stats.totalNewslettersSent}
|
||||
</span>
|
||||
<span className="newsletter-admin-stat-card-label">
|
||||
Newsletters Sent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="newsletter-admin-stat-card">
|
||||
<div className="newsletter-admin-stat-card-icon">
|
||||
<Users size={24} />
|
||||
</div>
|
||||
<div className="newsletter-admin-stat-card-content">
|
||||
<span className="newsletter-admin-stat-card-value">
|
||||
{stats.activeSubscribers}
|
||||
</span>
|
||||
<span className="newsletter-admin-stat-card-label">
|
||||
Active Subscribers
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="newsletter-admin-stat-card">
|
||||
<div className="newsletter-admin-stat-card-icon">
|
||||
<TrendUp size={24} />
|
||||
</div>
|
||||
<div className="newsletter-admin-stat-card-content">
|
||||
<span className="newsletter-admin-stat-card-value">
|
||||
{stats.totalSubscribers > 0
|
||||
? Math.round(
|
||||
(stats.activeSubscribers / stats.totalSubscribers) *
|
||||
100,
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</span>
|
||||
<span className="newsletter-admin-stat-card-label">
|
||||
Retention Rate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats summary */}
|
||||
<div className="newsletter-admin-stats-summary">
|
||||
<h3>Summary</h3>
|
||||
<div className="newsletter-admin-stats-row">
|
||||
<span>Total Subscribers</span>
|
||||
<span>{stats.totalSubscribers}</span>
|
||||
</div>
|
||||
<div className="newsletter-admin-stats-row">
|
||||
<span>Active Subscribers</span>
|
||||
<span>{stats.activeSubscribers}</span>
|
||||
</div>
|
||||
<div className="newsletter-admin-stats-row">
|
||||
<span>Unsubscribed</span>
|
||||
<span>{stats.unsubscribedCount}</span>
|
||||
</div>
|
||||
<div className="newsletter-admin-stats-row">
|
||||
<span>Newsletters Sent</span>
|
||||
<span>{stats.totalNewslettersSent}</span>
|
||||
</div>
|
||||
<div className="newsletter-admin-stats-row">
|
||||
<span>Total Emails Sent</span>
|
||||
<span>{stats.totalEmailsSent}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render send result message
|
||||
const renderSendResult = () => {
|
||||
if (!sendResult) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`newsletter-admin-send-result ${sendResult.success ? "success" : "error"}`}
|
||||
>
|
||||
<span className="newsletter-admin-result-message">
|
||||
{sendResult.message}
|
||||
</span>
|
||||
{sendResult.command && (
|
||||
<div className="newsletter-admin-command-row">
|
||||
<code className="newsletter-admin-command">
|
||||
{sendResult.command}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleCopyCommand(sendResult.command!)}
|
||||
className="newsletter-admin-copy-btn"
|
||||
title={copied ? "Copied!" : "Copy command"}
|
||||
>
|
||||
{copied ? <Check size={14} weight="bold" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="newsletter-admin-layout two-column">
|
||||
{/* Left Sidebar: Navigation and Stats */}
|
||||
<aside className="newsletter-admin-sidebar-left">
|
||||
<div className="newsletter-admin-sidebar-header">
|
||||
<Link
|
||||
to="/"
|
||||
className="newsletter-admin-logo-link"
|
||||
title="Back to home"
|
||||
>
|
||||
<House size={20} weight="regular" />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="newsletter-admin-nav">
|
||||
{/* Views section */}
|
||||
<div className="newsletter-admin-nav-section">
|
||||
<span className="newsletter-admin-nav-label">Views</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleViewModeChange("subscribers");
|
||||
handleFilterChange("all");
|
||||
}}
|
||||
className={`newsletter-admin-nav-item ${viewMode === "subscribers" && filter === "all" ? "active" : ""}`}
|
||||
>
|
||||
<Users
|
||||
size={18}
|
||||
weight={
|
||||
viewMode === "subscribers" && filter === "all"
|
||||
? "fill"
|
||||
: "regular"
|
||||
}
|
||||
/>
|
||||
<span>All Subscribers</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleViewModeChange("subscribers");
|
||||
handleFilterChange("subscribed");
|
||||
}}
|
||||
className={`newsletter-admin-nav-item ${viewMode === "subscribers" && filter === "subscribed" ? "active" : ""}`}
|
||||
>
|
||||
<Check
|
||||
size={18}
|
||||
weight={
|
||||
viewMode === "subscribers" && filter === "subscribed"
|
||||
? "bold"
|
||||
: "regular"
|
||||
}
|
||||
/>
|
||||
<span>Active</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleViewModeChange("subscribers");
|
||||
handleFilterChange("unsubscribed");
|
||||
}}
|
||||
className={`newsletter-admin-nav-item ${viewMode === "subscribers" && filter === "unsubscribed" ? "active" : ""}`}
|
||||
>
|
||||
<X
|
||||
size={18}
|
||||
weight={
|
||||
viewMode === "subscribers" && filter === "unsubscribed"
|
||||
? "bold"
|
||||
: "regular"
|
||||
}
|
||||
/>
|
||||
<span>Unsubscribed</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Send Newsletter section */}
|
||||
<div className="newsletter-admin-nav-section">
|
||||
<span className="newsletter-admin-nav-label">Send Newsletter</span>
|
||||
<button
|
||||
onClick={() => handleViewModeChange("send-post")}
|
||||
className={`newsletter-admin-nav-item ${viewMode === "send-post" ? "active" : ""}`}
|
||||
>
|
||||
<PaperPlaneTilt
|
||||
size={18}
|
||||
weight={viewMode === "send-post" ? "fill" : "regular"}
|
||||
/>
|
||||
<span>Send Post</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewModeChange("write-email")}
|
||||
className={`newsletter-admin-nav-item ${viewMode === "write-email" ? "active" : ""}`}
|
||||
>
|
||||
<PencilSimple
|
||||
size={18}
|
||||
weight={viewMode === "write-email" ? "fill" : "regular"}
|
||||
/>
|
||||
<span>Write Email</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* History section */}
|
||||
<div className="newsletter-admin-nav-section">
|
||||
<span className="newsletter-admin-nav-label">History</span>
|
||||
<button
|
||||
onClick={() => handleViewModeChange("recent-sends")}
|
||||
className={`newsletter-admin-nav-item ${viewMode === "recent-sends" ? "active" : ""}`}
|
||||
>
|
||||
<ClockCounterClockwise
|
||||
size={18}
|
||||
weight={viewMode === "recent-sends" ? "fill" : "regular"}
|
||||
/>
|
||||
<span>Recent Sends</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleViewModeChange("email-stats")}
|
||||
className={`newsletter-admin-nav-item ${viewMode === "email-stats" ? "active" : ""}`}
|
||||
>
|
||||
<ChartBar
|
||||
size={18}
|
||||
weight={viewMode === "email-stats" ? "fill" : "regular"}
|
||||
/>
|
||||
<span>Email Stats</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions section */}
|
||||
<div className="newsletter-admin-nav-section">
|
||||
<span className="newsletter-admin-nav-label">Actions</span>
|
||||
<button onClick={toggleTheme} className="newsletter-admin-nav-item">
|
||||
{getThemeIcon(theme)}
|
||||
<span>Theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Stats section */}
|
||||
{stats && (
|
||||
<div className="newsletter-admin-stats">
|
||||
<div className="newsletter-admin-stat">
|
||||
<span className="newsletter-admin-stat-value">
|
||||
{stats.activeSubscribers}
|
||||
</span>
|
||||
<span className="newsletter-admin-stat-label">Active</span>
|
||||
</div>
|
||||
<div className="newsletter-admin-stat">
|
||||
<span className="newsletter-admin-stat-value">
|
||||
{stats.totalSubscribers}
|
||||
</span>
|
||||
<span className="newsletter-admin-stat-label">Total</span>
|
||||
</div>
|
||||
<div className="newsletter-admin-stat">
|
||||
<span className="newsletter-admin-stat-value">
|
||||
{stats.totalNewslettersSent}
|
||||
</span>
|
||||
<span className="newsletter-admin-stat-label">Sent</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="newsletter-admin-main">
|
||||
<div className="newsletter-admin-main-header">
|
||||
<h1 className="newsletter-admin-main-title">
|
||||
{viewMode === "subscribers" && (
|
||||
<Envelope size={24} weight="regular" />
|
||||
)}
|
||||
{viewMode === "send-post" && (
|
||||
<PaperPlaneTilt size={24} weight="regular" />
|
||||
)}
|
||||
{viewMode === "write-email" && (
|
||||
<PencilSimple size={24} weight="regular" />
|
||||
)}
|
||||
{viewMode === "recent-sends" && (
|
||||
<ClockCounterClockwise size={24} weight="regular" />
|
||||
)}
|
||||
{viewMode === "email-stats" && (
|
||||
<ChartBar size={24} weight="regular" />
|
||||
)}
|
||||
{getMainTitle()}
|
||||
</h1>
|
||||
|
||||
{/* Search bar in header - only show for subscribers view */}
|
||||
{viewMode === "subscribers" && (
|
||||
<div className="newsletter-admin-search">
|
||||
<MagnifyingGlass size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by email..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="newsletter-admin-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderMainContent()}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import CopyPageDropdown from "../components/CopyPageDropdown";
|
||||
import PageSidebar from "../components/PageSidebar";
|
||||
import RightSidebar from "../components/RightSidebar";
|
||||
import Footer from "../components/Footer";
|
||||
import SocialFooter from "../components/SocialFooter";
|
||||
import NewsletterSignup from "../components/NewsletterSignup";
|
||||
import ContactForm from "../components/ContactForm";
|
||||
import { extractHeadings } from "../utils/extractHeadings";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { format, parseISO } from "date-fns";
|
||||
@@ -286,13 +289,34 @@ export default function Post({
|
||||
)}
|
||||
</header>
|
||||
|
||||
<BlogPost content={page.content} />
|
||||
<BlogPost content={page.content} slug={page.slug} pageType="page" />
|
||||
|
||||
{/* Contact form - shown when contactForm: true in frontmatter (only if not inline) */}
|
||||
{siteConfig.contactForm?.enabled && page.contactForm &&
|
||||
!page.content.includes("<!-- contactform -->") && (
|
||||
<ContactForm source={`page:${page.slug}`} />
|
||||
)}
|
||||
|
||||
{/* Newsletter signup - respects frontmatter override (only if not inline) */}
|
||||
{siteConfig.newsletter?.enabled &&
|
||||
(page.newsletter !== undefined
|
||||
? page.newsletter
|
||||
: siteConfig.newsletter.signup.posts.enabled) &&
|
||||
!page.content.includes("<!-- newsletter -->") && (
|
||||
<NewsletterSignup source="post" postSlug={page.slug} />
|
||||
)}
|
||||
|
||||
{/* Footer - shown inside article at bottom for pages */}
|
||||
{siteConfig.footer.enabled &&
|
||||
(page.showFooter !== undefined ? page.showFooter : siteConfig.footer.showOnPages) && (
|
||||
<Footer content={page.footer} />
|
||||
)}
|
||||
|
||||
{/* Social footer - shown inside article at bottom for pages */}
|
||||
{siteConfig.socialFooter?.enabled &&
|
||||
(page.showSocialFooter !== undefined ? page.showSocialFooter : siteConfig.socialFooter.showOnPages) && (
|
||||
<SocialFooter />
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* Right sidebar - with optional AI chat support */}
|
||||
@@ -445,7 +469,7 @@ export default function Post({
|
||||
)}
|
||||
</header>
|
||||
|
||||
<BlogPost content={post.content} />
|
||||
<BlogPost content={post.content} slug={post.slug} pageType="post" />
|
||||
|
||||
<footer className="post-footer">
|
||||
<div className="post-share">
|
||||
@@ -510,6 +534,21 @@ export default function Post({
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newsletter signup - respects frontmatter override (only if not inline) */}
|
||||
{siteConfig.newsletter?.enabled &&
|
||||
(post.newsletter !== undefined
|
||||
? post.newsletter
|
||||
: siteConfig.newsletter.signup.posts.enabled) &&
|
||||
!post.content.includes("<!-- newsletter -->") && (
|
||||
<NewsletterSignup source="post" postSlug={post.slug} />
|
||||
)}
|
||||
|
||||
{/* Contact form - shown when contactForm: true in frontmatter (only if not inline) */}
|
||||
{siteConfig.contactForm?.enabled && post.contactForm &&
|
||||
!post.content.includes("<!-- contactform -->") && (
|
||||
<ContactForm source={`post:${post.slug}`} />
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{/* Footer - shown inside article at bottom for posts */}
|
||||
@@ -517,6 +556,12 @@ export default function Post({
|
||||
(post.showFooter !== undefined ? post.showFooter : siteConfig.footer.showOnPosts) && (
|
||||
<Footer content={post.footer} />
|
||||
)}
|
||||
|
||||
{/* Social footer - shown inside article at bottom for posts */}
|
||||
{siteConfig.socialFooter?.enabled &&
|
||||
(post.showSocialFooter !== undefined ? post.showSocialFooter : siteConfig.socialFooter.showOnPosts) && (
|
||||
<SocialFooter />
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* Right sidebar - with optional AI chat support */}
|
||||
|
||||
@@ -97,25 +97,25 @@ export default function Stats() {
|
||||
},
|
||||
{
|
||||
number: "04",
|
||||
icon: GithubLogo,
|
||||
title: "GitHub Stars",
|
||||
value: githubStars ?? "...",
|
||||
description: "waynesutton/markdown-site",
|
||||
},
|
||||
{
|
||||
number: "05",
|
||||
icon: BookOpen,
|
||||
title: "Blog Posts",
|
||||
value: stats.publishedPosts,
|
||||
description: "Published posts",
|
||||
},
|
||||
{
|
||||
number: "05",
|
||||
number: "06",
|
||||
icon: FileText,
|
||||
title: "Pages",
|
||||
value: stats.publishedPages,
|
||||
description: "Static pages",
|
||||
},
|
||||
{
|
||||
number: "06",
|
||||
icon: GithubLogo,
|
||||
title: "GitHub Stars",
|
||||
value: githubStars ?? "...",
|
||||
description: "waynesutton/markdown-site",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
72
src/pages/Unsubscribe.tsx
Normal file
72
src/pages/Unsubscribe.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useSearchParams, Link } from "react-router-dom";
|
||||
import { useMutation } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
// Unsubscribe page component
|
||||
// Handles newsletter unsubscription via email and token from URL params
|
||||
export default function Unsubscribe() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const email = searchParams.get("email");
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "loading" | "success" | "error"
|
||||
>("idle");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const unsubscribeMutation = useMutation(api.newsletter.unsubscribe);
|
||||
|
||||
// Track if we've already attempted unsubscribe to prevent double calls
|
||||
const hasAttempted = useRef(false);
|
||||
|
||||
// Auto-unsubscribe when page loads with valid params
|
||||
useEffect(() => {
|
||||
if (email && token && !hasAttempted.current) {
|
||||
hasAttempted.current = true;
|
||||
handleUnsubscribe();
|
||||
}
|
||||
}, [email, token]);
|
||||
|
||||
const handleUnsubscribe = async () => {
|
||||
if (!email || !token) {
|
||||
setStatus("error");
|
||||
setMessage("Invalid unsubscribe link.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
|
||||
try {
|
||||
const result = await unsubscribeMutation({ email, token });
|
||||
setStatus(result.success ? "success" : "error");
|
||||
setMessage(result.message);
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setMessage("Something went wrong. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="unsubscribe-page">
|
||||
<h1>Unsubscribe</h1>
|
||||
|
||||
{status === "loading" && <p>Processing...</p>}
|
||||
|
||||
{status === "success" && (
|
||||
<>
|
||||
<p className="unsubscribe-success">{message}</p>
|
||||
<Link to="/" className="unsubscribe-home-link">
|
||||
Back to home
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "error" && <p className="unsubscribe-error">{message}</p>}
|
||||
|
||||
{status === "idle" && !email && !token && (
|
||||
<p>Use the unsubscribe link from your email.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,11 @@ const POST_FIELDS = [
|
||||
required: false,
|
||||
example: '"Built with [Convex](https://convex.dev)."',
|
||||
},
|
||||
{ name: "showSocialFooter", required: false, example: "true" },
|
||||
{ name: "aiChat", required: false, example: "true" },
|
||||
{ name: "blogFeatured", required: false, example: "true" },
|
||||
{ name: "newsletter", required: false, example: "true" },
|
||||
{ name: "contactForm", required: false, example: "true" },
|
||||
];
|
||||
|
||||
// Frontmatter field definitions for pages
|
||||
@@ -83,7 +87,10 @@ const PAGE_FIELDS = [
|
||||
required: false,
|
||||
example: '"Built with [Convex](https://convex.dev)."',
|
||||
},
|
||||
{ name: "showSocialFooter", required: false, example: "true" },
|
||||
{ name: "aiChat", required: false, example: "true" },
|
||||
{ name: "newsletter", required: false, example: "true" },
|
||||
{ name: "contactForm", required: false, example: "true" },
|
||||
];
|
||||
|
||||
// Generate frontmatter template based on content type
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user