Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
bc3282212f
commit
dc060d2b1a
70
docs/TITLE_FORMAT.md
Normal file
70
docs/TITLE_FORMAT.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# Daily Digest Title Format Standard
|
||||||
|
|
||||||
|
## Standard Format
|
||||||
|
|
||||||
|
All Daily Digest titles must follow this exact format:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Daily Digest - {DayName}, {Month} {Day}, {Year}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
✅ **Correct:**
|
||||||
|
- `## Daily Digest - Monday, February 24, 2026`
|
||||||
|
- `## Daily Digest - Tuesday, March 3, 2026`
|
||||||
|
- `## Daily Digest - Sunday, December 31, 2026`
|
||||||
|
|
||||||
|
❌ **Incorrect:**
|
||||||
|
- `## Daily Digest - February 24, 2026` (missing day name)
|
||||||
|
- `## Daily Digest - Monday, February 24th, 2026` (uses ordinal "24th")
|
||||||
|
- `# Daily Digest - Monday, February 24, 2026` (wrong header level)
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Header Level:** Always use `##` (H2), not `#` (H1) or `###` (H3)
|
||||||
|
2. **Day Name:** Always include the full day name (Monday, Tuesday, etc.)
|
||||||
|
3. **Month:** Full month name (January, February, etc.)
|
||||||
|
4. **Day:** Plain number without ordinal suffix (24, not 24th)
|
||||||
|
5. **Year:** Full 4-digit year (2026, not 26)
|
||||||
|
6. **Separators:** Comma after day name, comma after day number
|
||||||
|
|
||||||
|
## Code Reference
|
||||||
|
|
||||||
|
If you need to generate a title programmatically, use this function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getDayName(dateStr: string): string {
|
||||||
|
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
const date = new Date(dateStr + 'T12:00:00'); // Use noon to avoid timezone issues
|
||||||
|
return days[date.getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForTitle(dateStr: string): string {
|
||||||
|
const months = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
];
|
||||||
|
const [year, month, day] = dateStr.split('-');
|
||||||
|
const monthName = months[parseInt(month, 10) - 1];
|
||||||
|
return `${monthName} ${parseInt(day, 10)}, ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateStandardTitle(dateStr: string): string {
|
||||||
|
const dayName = getDayName(dateStr);
|
||||||
|
const datePart = formatDateForTitle(dateStr);
|
||||||
|
return `## Daily Digest - ${dayName}, ${datePart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage:
|
||||||
|
const title = generateStandardTitle('2026-02-24');
|
||||||
|
// Returns: "## Daily Digest - Tuesday, February 24, 2026"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration History
|
||||||
|
|
||||||
|
**Last Migration:** 2026-02-24
|
||||||
|
- Migrated 9 existing posts to the new standard format
|
||||||
|
- Script: `scripts/migrate-titles.js`
|
||||||
|
- Run with: `node scripts/migrate-titles.js`
|
||||||
|
- Dry run: `node scripts/migrate-titles.js --dry-run`
|
||||||
14
package-lock.json
generated
14
package-lock.json
generated
@ -22,6 +22,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
@ -3078,6 +3079,19 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||||
|
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@ -26,6 +26,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
179
scripts/migrate-titles.js
Normal file
179
scripts/migrate-titles.js
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Migration Script: Standardize Blog Titles
|
||||||
|
*
|
||||||
|
* This script standardizes all Daily Digest titles to the format:
|
||||||
|
* "## Daily Digest - Monday, February 24, 2026"
|
||||||
|
*
|
||||||
|
* Run: node migrate-titles.js [--dry-run]
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env.local') });
|
||||||
|
const { createClient } = require('@supabase/supabase-js');
|
||||||
|
|
||||||
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://qnatchrjlpehiijwtreh.supabase.co';
|
||||||
|
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!SUPABASE_SERVICE_KEY) {
|
||||||
|
console.error('Error: SUPABASE_SERVICE_ROLE_KEY or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variable required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get day name from date string (YYYY-MM-DD)
|
||||||
|
*/
|
||||||
|
function getDayName(dateStr) {
|
||||||
|
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
const date = new Date(dateStr + 'T12:00:00'); // Use noon to avoid timezone issues
|
||||||
|
return days[date.getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for title (e.g., "February 24, 2026")
|
||||||
|
*/
|
||||||
|
function formatDateForTitle(dateStr) {
|
||||||
|
const months = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
];
|
||||||
|
const [year, month, day] = dateStr.split('-');
|
||||||
|
const monthName = months[parseInt(month, 10) - 1];
|
||||||
|
return `${monthName} ${parseInt(day, 10)}, ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate standardized title
|
||||||
|
*/
|
||||||
|
function generateStandardTitle(dateStr) {
|
||||||
|
const dayName = getDayName(dateStr);
|
||||||
|
const datePart = formatDateForTitle(dateStr);
|
||||||
|
return `## Daily Digest - ${dayName}, ${datePart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract current title from content
|
||||||
|
*/
|
||||||
|
function extractCurrentTitle(content) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const titleLine = lines.find(l => l.startsWith('# ') || l.startsWith('## '));
|
||||||
|
return titleLine || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace title in content
|
||||||
|
*/
|
||||||
|
function replaceTitle(content, newTitle) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const titleIndex = lines.findIndex(l => l.startsWith('# ') || l.startsWith('## '));
|
||||||
|
|
||||||
|
if (titleIndex === -1) {
|
||||||
|
// No title found, prepend new title
|
||||||
|
return newTitle + '\n\n' + content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace existing title line
|
||||||
|
lines[titleIndex] = newTitle;
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all messages and update titles
|
||||||
|
*/
|
||||||
|
async function migrateTitles(dryRun = false) {
|
||||||
|
console.log('🔄 Fetching all blog messages...\n');
|
||||||
|
|
||||||
|
const { data: messages, error } = await supabase
|
||||||
|
.from('blog_messages')
|
||||||
|
.select('id, date, content')
|
||||||
|
.order('date', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching messages:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${messages.length} messages\n`);
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const skipped = [];
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const currentTitle = extractCurrentTitle(message.content);
|
||||||
|
const newTitle = generateStandardTitle(message.date);
|
||||||
|
|
||||||
|
// Check if update is needed
|
||||||
|
if (currentTitle === newTitle) {
|
||||||
|
skipped.push({
|
||||||
|
id: message.id,
|
||||||
|
date: message.date,
|
||||||
|
title: currentTitle,
|
||||||
|
reason: 'Already in standard format'
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push({
|
||||||
|
id: message.id,
|
||||||
|
date: message.date,
|
||||||
|
oldTitle: currentTitle,
|
||||||
|
newTitle: newTitle
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
const newContent = replaceTitle(message.content, newTitle);
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('blog_messages')
|
||||||
|
.update({ content: newContent })
|
||||||
|
.eq('id', message.id);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error(`❌ Error updating message ${message.id}:`, updateError);
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Updated: ${message.date}`);
|
||||||
|
console.log(` Old: ${currentTitle}`);
|
||||||
|
console.log(` New: ${newTitle}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log('MIGRATION SUMMARY');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log(`Total messages: ${messages.length}`);
|
||||||
|
console.log(`Updates needed: ${updates.length}`);
|
||||||
|
console.log(`Already correct: ${skipped.length}`);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log('\n📋 DRY RUN - No changes made\n');
|
||||||
|
if (updates.length > 0) {
|
||||||
|
console.log('Proposed changes:');
|
||||||
|
updates.forEach(u => {
|
||||||
|
console.log(`\n${u.date}:`);
|
||||||
|
console.log(` From: ${u.oldTitle}`);
|
||||||
|
console.log(` To: ${u.newTitle}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`\n✅ Migration complete!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updates, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main execution
|
||||||
|
const dryRun = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log('🔍 DRY RUN MODE - No changes will be made\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateTitles(dryRun)
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -37,8 +37,8 @@ export default function BlogPage() {
|
|||||||
function BlogPageContent() {
|
function BlogPageContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const selectedTag = searchParams.get("tag");
|
const selectedTag = searchParams?.get("tag") ?? null;
|
||||||
const selectedPostId = searchParams.get("post");
|
const selectedPostId = searchParams?.get("post") ?? null;
|
||||||
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { getDigests } from '../../lib/supabase';
|
|
||||||
|
|
||||||
export default function DigestList() {
|
|
||||||
const [digests, setDigests] = useState([]);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadDigests() {
|
|
||||||
try {
|
|
||||||
const data = await getDigests();
|
|
||||||
setDigests(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load digests');
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadDigests();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="text-red-500 p-4">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{digests.map((digest) => (
|
|
||||||
<div key={digest.date} className="border p-4 rounded">
|
|
||||||
<h3 className="text-lg font-bold">{digest.date}</h3>
|
|
||||||
<div className="mt-2 text-gray-700">{digest.content}</div>
|
|
||||||
|
|
||||||
{digest.audio_url && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<a
|
|
||||||
href={digest.audio_url}
|
|
||||||
className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Listen to Podcast
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
import { SupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import { create } from "apisauce";
|
|
||||||
import { ElevenLabs } from "elevenlabs";
|
|
||||||
|
|
||||||
const supabase = new SupabaseClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY);
|
|
||||||
const elevenLabs = new ElevenLabs(process.env.ELEVENLABS_API_KEY);
|
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
|
||||||
if (req.method !== "POST") return res.status(405).end();
|
|
||||||
|
|
||||||
const { date } = req.body;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch digest content
|
|
||||||
const { data: digest, error: fetchError } = await supabase
|
|
||||||
.from("digests")
|
|
||||||
.select("content")
|
|
||||||
.eq("date", date)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (fetchError) throw fetchError;
|
|
||||||
if (!digest) return res.status(404).json({ error: "Digest not found" });
|
|
||||||
|
|
||||||
// Generate audio
|
|
||||||
const audioResponse = await elevenLabs.textToSpeech({
|
|
||||||
text: digest.content,
|
|
||||||
voice: "nova",
|
|
||||||
model: "eleven_multilingual_v2"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save to Supabase storage
|
|
||||||
const audioBuffer = await audioResponse.arrayBuffer();
|
|
||||||
const { data: { publicUrl }, error: storageError } = await supabase.storage
|
|
||||||
.from("podcasts")
|
|
||||||
.upload(`digest-${date}.mp3`, Buffer.from(audioBuffer), {
|
|
||||||
upsert: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (storageError) throw storageError;
|
|
||||||
|
|
||||||
// Update digest record
|
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from("digests")
|
|
||||||
.update({ audio_url: publicUrl })
|
|
||||||
.eq("date", date);
|
|
||||||
|
|
||||||
if (updateError) throw updateError;
|
|
||||||
|
|
||||||
res.status(200).json({ audioUrl: publicUrl });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
res.status(500).json({ error: "Failed to generate podcast" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { SupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import { create } from "apisauce";
|
|
||||||
|
|
||||||
const supabase = new SupabaseClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY);
|
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
|
||||||
if (req.method !== "GET") return res.status(405).end();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch all digests ordered by date
|
|
||||||
const { data: digests, error } = await supabase
|
|
||||||
.from("digests")
|
|
||||||
.select("date, content, audio_url")
|
|
||||||
.order("date", { ascending: false });
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
// Generate RSS XML
|
|
||||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
|
||||||
<channel>
|
|
||||||
<title>Daily Digest Podcast</title>
|
|
||||||
<description>Audio version of the daily digest blog</description>
|
|
||||||
<link>https://blog-backup-two.vercel.app</link>
|
|
||||||
<itunes:subtitle>Audio version of the daily digest blog</itunes:subtitle>
|
|
||||||
<itunes:author>Daily Digest Team</itunes:author>
|
|
||||||
<itunes:category text="Technology"/>
|
|
||||||
|
|
||||||
${digests
|
|
||||||
.map(digest => {
|
|
||||||
return `
|
|
||||||
<item>
|
|
||||||
<title>Digest for ${digest.date}</title>
|
|
||||||
<description>${digest.content}</description>
|
|
||||||
<pubDate>${new Date(digest.date).toUTCString()}</pubDate>
|
|
||||||
<enclosure url="${digest.audio_url}" type="audio/mpeg"/>
|
|
||||||
<itunes:duration>5:00</itunes:duration>
|
|
||||||
</item>`;
|
|
||||||
})
|
|
||||||
.join("")}
|
|
||||||
</channel>
|
|
||||||
</rss>`;
|
|
||||||
|
|
||||||
res.setHeader("Content-Type", "application/xml");
|
|
||||||
res.status(200).send(xml);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
res.status(500).send("Error generating RSS feed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user