Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-03-03 09:11:53 -06:00
parent 6ed9c6d5b2
commit f8aa0b57b2
22 changed files with 2335 additions and 1408 deletions

107
README.md
View File

@ -1,72 +1,91 @@
# Daily Digest Blog
# Daily Digest Blog (blog-backup)
Next.js App Router blog with Supabase-backed posts, authenticated admin panel, and MP3 audio hosting.
Next.js app for an article-level daily digest. The core model is:
## Run locally
- One database row per article (`blog_articles`)
- Multiple articles can share the same `digest_date`
- RSS emits one `<item>` per article
## Local run
```bash
npm install
npm run dev
```
Dev server runs on `http://localhost:3002`.
Dev server: `http://localhost:3002`
## Environment variables
## Required env vars
Set these in `.env.local`:
Set in `.env.local`:
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY` (used by external scripts/tools if needed)
- `SUPABASE_SERVICE_ROLE_KEY`
- `CRON_API_KEY`
## Public vs admin access
## Database setup
- Public blog (`/`) is open to everyone.
- Reading messages (`GET /api/messages`) is public.
- Admin UI (`/admin`) requires a signed-in Supabase user.
- If not signed in, `/admin` redirects to `/login`.
- Write APIs (`POST/DELETE /api/messages`) require either:
- a valid Supabase user bearer token, or
- `x-api-key: <CRON_API_KEY>` (for automation/cron).
Run migrations in `supabase/migrations` (or apply `schema.sql` for a fresh setup).
## Login flow
Main table migration:
1. Open `/login`
2. Sign in with a Supabase Auth email/password user
3. You are redirected to `/admin`
- `supabase/migrations/20260303_create_blog_articles.sql`
## Digest automation endpoint
## API surface
- `POST /api/digest` requires `x-api-key: <CRON_API_KEY>`
- Used for cron-based digest publishing
### Primary article API
## RSS feeds
- `GET /api/articles` (public)
- `POST /api/articles` (auth required)
- `GET /api/articles/:id` (public)
- `PATCH /api/articles/:id` (auth required)
- `DELETE /api/articles/:id` (auth required)
- `GET /api/rss` - Standard digest RSS feed for blog/article consumers
- `GET /api/podcast/rss` - Podcast RSS feed (audio episodes)
Auth for write routes:
## MP3 Audio Hosting
- Supabase bearer token, or
- `x-api-key: <CRON_API_KEY>`
Upload and host MP3 files for individual blog posts.
### Digest ingestion API
### Features
- Upload audio files up to 50MB
- Custom audio player with play/pause, seek, and volume controls
- Audio indicator in admin post list
- Automatic duration estimation
- Stored in Supabase Storage (`podcast-audio` bucket)
- `POST /api/digest` (requires `x-api-key`)
- Accepts `date` + `articles[]`, inserts one `blog_articles` row per article
### Usage
1. Go to `/admin` and log in
2. Click "Edit" on any post
3. Scroll to "Audio Attachment" section
4. Upload an MP3 file
5. Save changes
### RSS
### API Endpoints
- `GET /api/audio?postId={id}` - Get audio info for a post
- `POST /api/audio` - Upload audio file (requires auth)
- `DELETE /api/audio` - Remove audio from post (requires auth)
- `GET /api/rss` - article feed (one item per article)
- `GET /api/podcast/rss` - podcast feed for rows with `audio_url`
See `docs/MP3_AUDIO_FEATURE.md` for full documentation.
### Legacy compatibility
- `GET/POST/DELETE /api/messages` is kept as a compatibility shim over `blog_articles`.
## UI pages
- `/` public article feed
- `/podcast` audio episode list from article rows with audio
- `/admin` authenticated article CRUD + audio upload
- `/login` Supabase auth
## CLI
Use project CLI:
```bash
./scripts/blog.sh --help
```
Recommended commands:
- `article-add`
- `article-list`
- `article-delete`
- `rss`
## Audio
Audio files are stored in Supabase Storage bucket `podcast-audio` and linked via:
- `blog_articles.audio_url`
- `blog_articles.audio_duration`

View File

@ -0,0 +1 @@
{"lastChecks": {"email": 1772500752, "calendar": 1772503603, "weather": null, "missionControl": 1772503603, "git": 1772503603}}

View File

@ -1,24 +1,46 @@
-- Blog Backup Database Schema
-- Run this SQL in Supabase Dashboard SQL Editor
-- Blog Backup database schema (article-first).
-- Apply in Supabase SQL Editor for a fresh setup.
-- Add audio URL column for storing podcast audio file URLs
ALTER TABLE blog_messages
ADD COLUMN IF NOT EXISTS audio_url TEXT;
CREATE TABLE IF NOT EXISTS public.blog_articles (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
summary TEXT NOT NULL,
source_url TEXT,
digest_date DATE,
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tags TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
audio_url TEXT,
audio_duration INTEGER,
is_published BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Add audio duration column (in seconds)
ALTER TABLE blog_messages
ADD COLUMN IF NOT EXISTS audio_duration INTEGER;
CREATE INDEX IF NOT EXISTS idx_blog_articles_published_at
ON public.blog_articles (published_at DESC);
-- Create index for faster queries on posts with audio
CREATE INDEX IF NOT EXISTS idx_blog_messages_audio
ON blog_messages(audio_url)
WHERE audio_url IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_blog_articles_digest_date
ON public.blog_articles (digest_date DESC);
COMMENT ON COLUMN blog_messages.audio_url IS 'Public URL to the audio file in Supabase Storage';
COMMENT ON COLUMN blog_messages.audio_duration IS 'Audio duration in seconds (estimated from file size)';
CREATE INDEX IF NOT EXISTS idx_blog_articles_tags
ON public.blog_articles USING GIN (tags);
-- Verify columns were added
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'blog_messages'
ORDER BY ordinal_position;
CREATE INDEX IF NOT EXISTS idx_blog_articles_audio
ON public.blog_articles (audio_url)
WHERE audio_url IS NOT NULL;
COMMENT ON TABLE public.blog_articles IS
'One row per article. digest_date groups rows into a daily digest.';
COMMENT ON COLUMN public.blog_articles.summary IS
'Structured article summary body shown in UI and RSS.';
COMMENT ON COLUMN public.blog_articles.digest_date IS
'Logical daily digest date (YYYY-MM-DD). Multiple articles can share one date.';
COMMENT ON COLUMN public.blog_articles.is_published IS
'Controls public visibility in /api/articles and RSS.';
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE table_name IN ('blog_articles')
ORDER BY table_name, ordinal_position;

View File

@ -1,296 +1,444 @@
#!/bin/bash
# Blog Backup CLI - Main Entry Point
# Usage: ./blog.sh <command> [args]
#
# Commands:
# post Create a new digest post
# list List digests
# get Get a specific digest by ID
# delete Delete a digest
# search Search digests by content
# status Check if digest exists for a date
# health Check API health
#
# Environment:
# BLOG_API_URL Blog Backup API URL (default: https://blog-backup-two.vercel.app/api)
# BLOG_MACHINE_TOKEN Machine token for API auth (required)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DEFAULT_BLOG_API_URL="https://blog.twisteddevices.com/api"
# Set default API URL
export BLOG_API_URL="${BLOG_API_URL:-https://blog-backup-two.vercel.app/api}"
# Check for machine token
if [[ -z "${BLOG_MACHINE_TOKEN:-}" ]]; then
# Try to load from .env.local
if [[ -f "$PROJECT_ROOT/.env.local" ]]; then
source "$PROJECT_ROOT/.env.local"
fi
# Check if CRON_API_KEY is available
if [[ -n "${CRON_API_KEY:-}" ]]; then
export BLOG_MACHINE_TOKEN="$CRON_API_KEY"
fi
if [[ -f "$PROJECT_ROOT/.env.local" ]]; then
source "$PROJECT_ROOT/.env.local"
fi
export BLOG_API_URL="${BLOG_API_URL:-$DEFAULT_BLOG_API_URL}"
if [[ -z "${BLOG_MACHINE_TOKEN:-}" ]]; then
if [[ -n "${CRON_API_KEY:-}" ]]; then
export BLOG_MACHINE_TOKEN="$CRON_API_KEY"
fi
fi
# Source the skill library
source "${HOME}/.agents/skills/blog-backup/lib/blog.sh"
# Show usage
url_encode() {
jq -rn --arg value "${1:-}" '$value|@uri'
}
API_STATUS=""
API_BODY=""
api_call() {
local method="$1"
local endpoint="$2"
local payload="${3:-}"
local url="${BLOG_API_URL}${endpoint}"
local curl_args=(-sS -X "$method" "$url")
local tmp_body
local http_code
tmp_body="$(mktemp)"
if [[ -n "${BLOG_MACHINE_TOKEN:-}" ]]; then
curl_args+=(-H "x-api-key: ${BLOG_MACHINE_TOKEN}")
fi
if [[ -n "$payload" ]]; then
curl_args+=(-H "Content-Type: application/json" -d "$payload")
fi
if ! http_code="$(curl "${curl_args[@]}" -o "$tmp_body" -w "%{http_code}")"; then
rm -f "$tmp_body"
return 1
fi
API_STATUS="$http_code"
API_BODY="$(cat "$tmp_body")"
rm -f "$tmp_body"
return 0
}
is_json() {
jq -e . >/dev/null 2>&1 <<<"${1:-}"
}
is_http_success() {
local code="${1:-0}"
[[ "$code" =~ ^[0-9]+$ ]] && (( code >= 200 && code < 300 ))
}
print_api_error() {
local endpoint="$1"
echo "❌ API request failed: ${BLOG_API_URL}${endpoint} (HTTP ${API_STATUS:-unknown})" >&2
if is_json "$API_BODY"; then
local message
message="$(jq -r '.error // .message // empty' <<<"$API_BODY")"
if [[ -n "$message" ]]; then
echo "Error: $message" >&2
fi
echo "$API_BODY" | jq . >&2
return
fi
echo "Response (non-JSON):" >&2
printf '%s\n' "$API_BODY" | head -c 800 >&2
echo >&2
if [[ "${BLOG_API_URL}" == "https://blog.twisteddevices.com/api" && "$API_BODY" == *"This page could not be found."* ]]; then
echo "Hint: /api/articles is not deployed there yet. For local testing use BLOG_API_URL=http://localhost:3002/api" >&2
fi
}
usage() {
cat << 'EOF'
cat <<'EOF'
Blog Backup CLI
Usage: ./blog.sh <command> [args]
Commands:
post [args] Create a new digest post
--date YYYY-MM-DD Date for the digest (required)
--content "..." Content in markdown (required)
--tags '["tag1","tag2"]' Tags as JSON array (default: ["daily-digest"])
Environment:
BLOG_API_URL API base (default: https://blog.twisteddevices.com/api)
For local dev use: BLOG_API_URL=http://localhost:3002/api
BLOG_MACHINE_TOKEN Optional machine token (falls back to CRON_API_KEY from .env.local)
list [args] List digests
--limit N Max results (default: 20)
--since YYYY-MM-DD Filter by date
--json Output as JSON
Modern article commands:
article-add [args] Add one article (recommended)
--title "..." Article title (required)
--summary "..." Article summary (required)
--url "https://..." Source URL (optional)
--date YYYY-MM-DD Digest date (default: today)
--published-at ISO8601 Published timestamp (optional)
--tags "a,b,c" Comma-separated tags (optional)
--json Output full JSON
get <id> Get a specific digest by ID
article-list [args] List article rows
--date YYYY-MM-DD Filter by digest day
--tag TAG Filter by tag
--limit N Max rows (default: 50)
--json Output full JSON
delete <id> Delete a digest by ID
article-delete <id> Delete one article by id
rss Print RSS feed URL
Legacy digest commands:
post [args] Create a bundled digest post (legacy)
list [args] List digests (legacy)
get <id> Get digest by ID
delete <id> Delete digest by ID
search <query> Search digests by content
--limit N Max results (default: 20)
status <date> Check if digest exists for a date (YYYY-MM-DD)
status <date> Check if digest exists for date
health Check API health
Examples:
./blog.sh post --date 2026-02-26 --content "# Daily Digest" --tags '["AI", "iOS"]'
./blog.sh list --limit 10
./blog.sh get 1234567890
./blog.sh search "OpenClaw"
./blog.sh status 2026-02-26
Environment Variables:
BLOG_API_URL Blog Backup API URL
BLOG_MACHINE_TOKEN Machine token for API auth
EOF
}
# Post command
handle_post() {
local DATE=""
local CONTENT=""
local TAGS='["daily-digest"]'
handle_article_add() {
local TITLE=""
local SUMMARY=""
local SOURCE_URL=""
local DIGEST_DATE="$(date +%F)"
local PUBLISHED_AT=""
local TAGS=""
local JSON_OUTPUT=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--date)
DATE="$2"
shift 2
;;
--content)
CONTENT="$2"
shift 2
;;
--tags)
TAGS="$2"
shift 2
;;
*)
echo "Unknown option: $1" >&2
shift
;;
esac
done
# Validate required fields
if [[ -z "$DATE" ]]; then
echo "Usage: ./blog.sh post --date <YYYY-MM-DD> --content <content> [--tags <tags>]" >&2
exit 1
fi
if [[ -z "$CONTENT" ]]; then
echo "Error: --content is required" >&2
exit 1
fi
# Create the digest
local DIGEST_ID
DIGEST_ID=$(blog_post_create --date "$DATE" --content "$CONTENT" --tags "$TAGS")
if [[ $? -eq 0 ]]; then
echo "✅ Digest created: $DIGEST_ID"
echo "URL: https://blog-backup-two.vercel.app"
else
echo "❌ Failed to create digest" >&2
exit 1
fi
}
# List command
handle_list() {
local LIMIT=20
local SINCE=""
local JSON_OUTPUT=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--limit)
LIMIT="$2"
shift 2
;;
--since)
SINCE="$2"
shift 2
;;
--json)
JSON_OUTPUT=true
shift
;;
*)
shift
;;
esac
done
if [[ "$JSON_OUTPUT" == "true" ]]; then
blog_post_list --limit "$LIMIT" ${SINCE:+--since "$SINCE"} --json | jq .
else
echo "=== Recent Digests ==="
blog_post_list --limit "$LIMIT" ${SINCE:+--since "$SINCE"}
fi
}
# Get command
handle_get() {
local ID="${1:-}"
if [[ -z "$ID" ]]; then
echo "Usage: ./blog.sh get <id>" >&2
exit 1
fi
local RESULT
RESULT=$(blog_post_get "$ID")
if [[ -n "$RESULT" ]]; then
echo "$RESULT" | jq .
else
echo "❌ Digest not found: $ID" >&2
exit 1
fi
}
# Delete command
handle_delete() {
local ID="${1:-}"
if [[ -z "$ID" ]]; then
echo "Usage: ./blog.sh delete <id>" >&2
exit 1
fi
blog_post_delete "$ID"
}
# Search command
handle_search() {
local QUERY="${1:-}"
local LIMIT=20
if [[ -z "$QUERY" ]]; then
echo "Usage: ./blog.sh search <query> [--limit N]" >&2
exit 1
fi
# Check for --limit flag
if [[ "$2" == "--limit" && -n "${3:-}" ]]; then
LIMIT="$3"
fi
echo "=== Search Results for '$QUERY' ==="
blog_post_search "$QUERY" --limit "$LIMIT" | jq -r '.[] | "\(.id) | \(.date) | \(.content | .[0:50])..."'
}
# Status command
handle_status() {
local DATE="${1:-}"
if [[ -z "$DATE" ]]; then
echo "Usage: ./blog.sh status <YYYY-MM-DD>" >&2
exit 1
fi
if blog_post_status "$DATE"; then
echo "✅ Digest exists for $DATE"
else
echo "❌ No digest found for $DATE"
fi
}
# Health command
handle_health() {
echo "=== API Health Check ==="
local RESULT
RESULT=$(blog_health)
if [[ -n "$RESULT" ]]; then
echo "✅ API is accessible"
echo "$RESULT" | jq . 2>/dev/null || echo "$RESULT"
else
echo "❌ API is not responding" >&2
exit 1
fi
}
# Main command dispatcher
main() {
local cmd="${1:-}"
shift || true
case "$cmd" in
post)
handle_post "$@"
;;
list)
handle_list "$@"
;;
get)
handle_get "$@"
;;
delete)
handle_delete "$@"
;;
search)
handle_search "$@"
;;
status)
handle_status "$@"
;;
health)
handle_health
;;
help|--help|-h)
usage
;;
*)
if [[ -z "$cmd" ]]; then
usage
exit 1
fi
echo "Unknown command: $cmd" >&2
usage
exit 1
;;
while [[ $# -gt 0 ]]; do
case "$1" in
--title) TITLE="${2:-}"; shift 2 ;;
--summary) SUMMARY="${2:-}"; shift 2 ;;
--url) SOURCE_URL="${2:-}"; shift 2 ;;
--date) DIGEST_DATE="${2:-}"; shift 2 ;;
--published-at) PUBLISHED_AT="${2:-}"; shift 2 ;;
--tags) TAGS="${2:-}"; shift 2 ;;
--json) JSON_OUTPUT=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
if [[ -z "$TITLE" || -z "$SUMMARY" ]]; then
echo "Usage: ./blog.sh article-add --title <title> --summary <summary> [--url <url>] [--date <YYYY-MM-DD>] [--tags <a,b,c>]" >&2
exit 1
fi
if [[ -z "${BLOG_MACHINE_TOKEN:-}" ]]; then
echo "Error: BLOG_MACHINE_TOKEN (or CRON_API_KEY in .env.local) is required for article-add." >&2
exit 1
fi
local payload
payload="$(jq -n \
--arg title "$TITLE" \
--arg summary "$SUMMARY" \
--arg sourceUrl "$SOURCE_URL" \
--arg digestDate "$DIGEST_DATE" \
--arg publishedAt "$PUBLISHED_AT" \
--arg tagsCsv "$TAGS" \
'{
title: $title,
summary: $summary,
sourceUrl: (if $sourceUrl == "" then null else $sourceUrl end),
digestDate: (if $digestDate == "" then null else $digestDate end),
publishedAt: (if $publishedAt == "" then null else $publishedAt end),
tags: (if $tagsCsv == "" then [] else ($tagsCsv | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) end)
}')"
if ! api_call POST "/articles" "$payload"; then
echo "❌ Failed to reach API endpoint." >&2
exit 1
fi
if ! is_http_success "$API_STATUS"; then
print_api_error "/articles"
exit 1
fi
if [[ "$JSON_OUTPUT" == "true" ]]; then
if is_json "$API_BODY"; then
echo "$API_BODY" | jq .
else
echo "$API_BODY"
fi
return
fi
if ! is_json "$API_BODY"; then
echo "❌ API returned non-JSON success response." >&2
echo "$API_BODY" >&2
exit 1
fi
echo "$API_BODY" | jq -r '"✅ Article added: \(.article.id // "unknown") | \(.article.title // "unknown")"'
}
handle_article_list() {
local DATE=""
local TAG=""
local LIMIT=50
local JSON_OUTPUT=false
while [[ $# -gt 0 ]]; do
case "$1" in
--date) DATE="${2:-}"; shift 2 ;;
--tag) TAG="${2:-}"; shift 2 ;;
--limit) LIMIT="${2:-}"; shift 2 ;;
--json) JSON_OUTPUT=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
local endpoint="/articles?limit=${LIMIT}"
if [[ -n "$DATE" ]]; then
endpoint="${endpoint}&digestDate=$(url_encode "$DATE")"
fi
if [[ -n "$TAG" ]]; then
endpoint="${endpoint}&tag=$(url_encode "$TAG")"
fi
if ! api_call GET "$endpoint"; then
echo "❌ Failed to reach API endpoint." >&2
exit 1
fi
if ! is_http_success "$API_STATUS"; then
print_api_error "$endpoint"
exit 1
fi
if [[ "$JSON_OUTPUT" == "true" ]]; then
if is_json "$API_BODY"; then
echo "$API_BODY" | jq .
else
echo "$API_BODY"
fi
return
fi
if ! is_json "$API_BODY"; then
echo "❌ API returned non-JSON response." >&2
echo "$API_BODY" >&2
exit 1
fi
echo "$API_BODY" | jq -r '.articles[]? | "\(.id)\t\(.digestDate)\t\(.title)\t\(.sourceUrl // "-")"'
}
handle_article_delete() {
local ID="${1:-}"
if [[ -z "$ID" ]]; then
echo "Usage: ./blog.sh article-delete <id>" >&2
exit 1
fi
if [[ -z "${BLOG_MACHINE_TOKEN:-}" ]]; then
echo "Error: BLOG_MACHINE_TOKEN (or CRON_API_KEY in .env.local) is required for article-delete." >&2
exit 1
fi
local endpoint="/articles/${ID}"
if ! api_call DELETE "$endpoint"; then
echo "❌ Failed to reach API endpoint." >&2
exit 1
fi
if ! is_http_success "$API_STATUS"; then
print_api_error "$endpoint"
exit 1
fi
if is_json "$API_BODY"; then
echo "$API_BODY" | jq .
else
echo "$API_BODY"
fi
}
handle_rss() {
local base_url="${BLOG_API_URL%/api}"
echo "${base_url}/api/rss"
}
handle_post() {
local DATE=""
local CONTENT=""
local TAGS='["daily-digest"]'
while [[ $# -gt 0 ]]; do
case "$1" in
--date) DATE="$2"; shift 2 ;;
--content) CONTENT="$2"; shift 2 ;;
--tags) TAGS="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; shift ;;
esac
done
if [[ -z "$DATE" ]]; then
echo "Usage: ./blog.sh post --date <YYYY-MM-DD> --content <content> [--tags <tags>]" >&2
exit 1
fi
if [[ -z "$CONTENT" ]]; then
echo "Error: --content is required" >&2
exit 1
fi
local DIGEST_ID
DIGEST_ID=$(blog_post_create --date "$DATE" --content "$CONTENT" --tags "$TAGS")
if [[ $? -eq 0 ]]; then
echo "✅ Digest created: $DIGEST_ID"
else
echo "❌ Failed to create digest" >&2
exit 1
fi
}
handle_list() {
local LIMIT=20
local SINCE=""
local JSON_OUTPUT=false
while [[ $# -gt 0 ]]; do
case "$1" in
--limit) LIMIT="$2"; shift 2 ;;
--since) SINCE="$2"; shift 2 ;;
--json) JSON_OUTPUT=true; shift ;;
*) shift ;;
esac
done
if [[ "$JSON_OUTPUT" == "true" ]]; then
blog_post_list --limit "$LIMIT" ${SINCE:+--since "$SINCE"} --json | jq .
else
echo "=== Recent Digests ==="
blog_post_list --limit "$LIMIT" ${SINCE:+--since "$SINCE"}
fi
}
handle_get() {
local ID="${1:-}"
if [[ -z "$ID" ]]; then
echo "Usage: ./blog.sh get <id>" >&2
exit 1
fi
local RESULT
RESULT=$(blog_post_get "$ID")
if [[ -n "$RESULT" ]]; then
echo "$RESULT" | jq .
else
echo "❌ Digest not found: $ID" >&2
exit 1
fi
}
handle_delete() {
local ID="${1:-}"
if [[ -z "$ID" ]]; then
echo "Usage: ./blog.sh delete <id>" >&2
exit 1
fi
blog_post_delete "$ID"
}
handle_search() {
local QUERY="${1:-}"
local LIMIT=20
if [[ -z "$QUERY" ]]; then
echo "Usage: ./blog.sh search <query> [--limit N]" >&2
exit 1
fi
if [[ "${2:-}" == "--limit" && -n "${3:-}" ]]; then
LIMIT="$3"
fi
echo "=== Search Results for '$QUERY' ==="
blog_post_search "$QUERY" --limit "$LIMIT" | jq -r '.[] | "\(.id) | \(.date) | \(.content | .[0:50])..."'
}
handle_status() {
local DATE="${1:-}"
if [[ -z "$DATE" ]]; then
echo "Usage: ./blog.sh status <YYYY-MM-DD>" >&2
exit 1
fi
if blog_post_status "$DATE"; then
echo "✅ Digest exists for $DATE"
else
echo "❌ No digest found for $DATE"
fi
}
handle_health() {
echo "=== API Health Check ==="
local RESULT
RESULT=$(blog_health)
if [[ -n "$RESULT" ]]; then
echo "✅ API is accessible"
echo "$RESULT" | jq . 2>/dev/null || echo "$RESULT"
else
echo "❌ API is not responding" >&2
exit 1
fi
}
main() {
local cmd="${1:-}"
shift || true
case "$cmd" in
article-add) handle_article_add "$@" ;;
article-list) handle_article_list "$@" ;;
article-delete) handle_article_delete "$@" ;;
rss) handle_rss ;;
post) handle_post "$@" ;;
list) handle_list "$@" ;;
get) handle_get "$@" ;;
delete) handle_delete "$@" ;;
search) handle_search "$@" ;;
status) handle_status "$@" ;;
health) handle_health ;;
help|--help|-h|"") usage ;;
*)
echo "Unknown command: $cmd" >&2
usage
exit 1
;;
esac
}
main "$@"

View File

@ -1,21 +1,21 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useMemo, useState } from "react";
import { createClient, type User } from "@supabase/supabase-js";
import { useRouter } from "next/navigation";
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { AudioUpload } from "@/components/AudioUpload";
interface Message {
interface Article {
id: string;
date: string;
content: string;
timestamp: number;
tags?: string[];
audio_url?: string | null;
audio_duration?: number | null;
title: string;
summary: string;
sourceUrl: string | null;
digestDate: string | null;
publishedAt: string;
tags: string[];
audioUrl: string | null;
audioDuration: number | null;
}
type Theme = "light" | "dark";
@ -25,32 +25,38 @@ const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
function toTagArray(input: string): string[] {
return input
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export default function AdminPage() {
const router = useRouter();
const [messages, setMessages] = useState<Message[]>([]);
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [editingPost, setEditingPost] = useState<Message | null>(null);
const [editContent, setEditContent] = useState("");
const [editDate, setEditDate] = useState("");
const [editTags, setEditTags] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const [newContent, setNewContent] = useState("");
const [newDate, setNewDate] = useState("");
const [newTags, setNewTags] = useState("daily-digest");
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === "undefined") {
return "light";
}
const storedTheme = localStorage.getItem("theme");
if (storedTheme === "light" || storedTheme === "dark") {
return storedTheme;
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") {
return stored;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
});
const [showCreateModal, setShowCreateModal] = useState(false);
const [editing, setEditing] = useState<Article | null>(null);
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
const [sourceUrl, setSourceUrl] = useState("");
const [digestDate, setDigestDate] = useState("");
const [tagsText, setTagsText] = useState("daily-digest");
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
localStorage.setItem("theme", theme);
@ -58,25 +64,36 @@ export default function AdminPage() {
useEffect(() => {
async function runAuthCheck() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
const {
data: { user: activeUser },
} = await supabase.auth.getUser();
if (!activeUser) {
router.replace("/login");
return;
}
setUser(user);
setUser(activeUser);
}
runAuthCheck();
void runAuthCheck();
}, [router]);
useEffect(() => {
if (user) {
fetchMessages();
void fetchArticles();
}
}, [user]);
const sortedArticles = useMemo(
() =>
[...articles].sort(
(a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
),
[articles]
);
async function getAuthHeaders() {
const { data: { session } } = await supabase.auth.getSession();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error("No active session");
}
@ -92,128 +109,125 @@ export default function AdminPage() {
router.replace("/login");
}
async function fetchMessages() {
async function fetchArticles() {
try {
const res = await fetch("/api/messages");
const data = await res.json();
if (Array.isArray(data)) {
setMessages(data.sort((a, b) => b.timestamp - a.timestamp));
}
const res = await fetch("/api/articles?limit=500");
const payload = (await res.json()) as { articles?: Article[] };
setArticles(Array.isArray(payload.articles) ? payload.articles : []);
} catch (error) {
console.error("Error fetching messages:", error);
console.error("Error fetching articles:", error);
} finally {
setLoading(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Are you sure you want to delete this post?")) return;
try {
const headers = await getAuthHeaders();
const res = await fetch("/api/messages", {
method: "DELETE",
headers,
body: JSON.stringify({ id }),
});
if (res.ok) {
setMessages(messages.filter((m) => m.id !== id));
} else if (res.status === 401) {
await handleUnauthorized();
} else {
alert("Failed to delete post");
}
} catch (error) {
console.error("Error deleting:", error);
await handleUnauthorized();
alert("Error deleting post");
}
function resetForm() {
setTitle("");
setSummary("");
setSourceUrl("");
setDigestDate("");
setTagsText("daily-digest");
}
function handleEdit(post: Message) {
setEditingPost(post);
setEditContent(post.content);
setEditDate(post.date);
setEditTags(post.tags?.join(", ") || "");
}
async function handleSaveEdit() {
if (!editingPost) return;
try {
const headers = await getAuthHeaders();
// Delete old post
const deleteRes = await fetch("/api/messages", {
method: "DELETE",
headers,
body: JSON.stringify({ id: editingPost.id }),
});
if (deleteRes.status === 401) {
await handleUnauthorized();
return;
}
if (!deleteRes.ok) {
alert("Failed to save changes");
return;
}
// Create new post with updated content
const res = await fetch("/api/messages", {
method: "POST",
headers,
body: JSON.stringify({
content: editContent,
date: editDate,
tags: editTags.split(",").map((t) => t.trim()).filter(Boolean),
}),
});
if (res.ok) {
setEditingPost(null);
fetchMessages();
} else if (res.status === 401) {
await handleUnauthorized();
} else {
alert("Failed to save changes");
}
} catch (error) {
console.error("Error saving:", error);
await handleUnauthorized();
alert("Error saving changes");
}
function loadFormFromArticle(article: Article) {
setTitle(article.title);
setSummary(article.summary);
setSourceUrl(article.sourceUrl || "");
setDigestDate(article.digestDate || article.publishedAt.slice(0, 10));
setTagsText(article.tags.join(", "));
}
async function handleCreate() {
try {
const headers = await getAuthHeaders();
const res = await fetch("/api/messages", {
const res = await fetch("/api/articles", {
method: "POST",
headers,
body: JSON.stringify({
content: newContent,
date: newDate,
tags: newTags.split(",").map((t) => t.trim()).filter(Boolean),
title,
summary,
sourceUrl: sourceUrl || null,
digestDate: digestDate || null,
tags: toTagArray(tagsText),
}),
});
if (res.ok) {
setShowCreateModal(false);
setNewContent("");
setNewDate("");
setNewTags("daily-digest");
fetchMessages();
} else if (res.status === 401) {
await handleUnauthorized();
} else {
alert("Failed to create post");
resetForm();
await fetchArticles();
return;
}
if (res.status === 401) {
await handleUnauthorized();
return;
}
alert("Failed to create article");
} catch (error) {
console.error("Error creating:", error);
console.error("Error creating article:", error);
await handleUnauthorized();
}
}
async function handleSaveEdit() {
if (!editing) {
return;
}
try {
const headers = await getAuthHeaders();
const res = await fetch(`/api/articles/${editing.id}`, {
method: "PATCH",
headers,
body: JSON.stringify({
title,
summary,
sourceUrl: sourceUrl || null,
digestDate: digestDate || null,
tags: toTagArray(tagsText),
}),
});
if (res.ok) {
setEditing(null);
resetForm();
await fetchArticles();
return;
}
if (res.status === 401) {
await handleUnauthorized();
return;
}
alert("Failed to update article");
} catch (error) {
console.error("Error updating article:", error);
await handleUnauthorized();
}
}
async function handleDelete(id: string) {
if (!confirm("Delete this article?")) {
return;
}
try {
const headers = await getAuthHeaders();
const res = await fetch(`/api/articles/${id}`, {
method: "DELETE",
headers,
});
if (res.ok) {
setArticles((prev) => prev.filter((item) => item.id !== id));
return;
}
if (res.status === 401) {
await handleUnauthorized();
return;
}
alert("Failed to delete article");
} catch (error) {
console.error("Error deleting article:", error);
await handleUnauthorized();
alert("Error creating post");
}
}
@ -248,17 +262,22 @@ export default function AdminPage() {
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
>
{theme === "dark" ? "Light mode" : "Dark mode"}
</button>
<button
onClick={() => setShowCreateModal(true)}
onClick={() => {
resetForm();
setShowCreateModal(true);
}}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
+ New Post
+ New Article
</button>
<Link href="/" className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800">
<Link
href="/"
className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"
>
View Site
</Link>
<button
@ -274,21 +293,19 @@ export default function AdminPage() {
<main className="max-w-6xl mx-auto px-4 py-8">
<div className="bg-white rounded-lg shadow border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
<div className="px-6 py-4 border-b border-gray-200 dark:border-slate-800">
<h2 className="text-lg font-semibold">
All Posts ({messages.length})
</h2>
<h2 className="text-lg font-semibold">All Articles ({articles.length})</h2>
</div>
<div className="divide-y divide-gray-200 dark:divide-slate-800">
{messages.map((post) => (
<div key={post.id} className="p-6 hover:bg-gray-50 dark:hover:bg-slate-800/40">
<div className="flex items-start justify-between">
{sortedArticles.map((article) => (
<div key={article.id} className="p-6 hover:bg-gray-50 dark:hover:bg-slate-800/40">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-sm text-gray-500 dark:text-slate-400">
{new Date(post.timestamp).toLocaleDateString()}
{new Date(article.publishedAt).toLocaleString()}
</span>
{post.tags?.map((tag) => (
{(article.tags || []).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded dark:bg-blue-900/40 dark:text-blue-200"
@ -296,28 +313,41 @@ export default function AdminPage() {
{tag}
</span>
))}
{post.audio_url && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded flex items-center gap-1 dark:bg-green-900/40 dark:text-green-200">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.983 5.983 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
{article.audioUrl && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded dark:bg-green-900/40 dark:text-green-200">
Audio
</span>
)}
</div>
<div className="text-sm text-gray-600 dark:text-slate-300 line-clamp-2">
{post.content.substring(0, 200)}...
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-slate-100 mb-1">
{article.title}
</h3>
<p className="text-sm text-gray-600 dark:text-slate-300 line-clamp-2">
{article.summary}
</p>
{article.sourceUrl && (
<a
href={article.sourceUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{article.sourceUrl}
</a>
)}
</div>
<div className="flex gap-2 ml-4">
<div className="flex gap-2">
<button
onClick={() => handleEdit(post)}
onClick={() => {
setEditing(article);
loadFormFromArticle(article);
}}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"
>
Edit
</button>
<button
onClick={() => handleDelete(post.id)}
onClick={() => void handleDelete(article.id)}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
@ -330,142 +360,113 @@ export default function AdminPage() {
</div>
</main>
{/* Edit Modal */}
{editingPost && (
{(showCreateModal || editing) && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
<div className="p-6 border-b border-gray-200 dark:border-slate-800">
<h3 className="text-lg font-semibold">Edit Post</h3>
<h3 className="text-lg font-semibold">{editing ? "Edit Article" : "New Article"}</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Date</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">
Title
</label>
<input
type="text"
value={editDate}
onChange={(e) => setEditDate(e.target.value)}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Tags (comma separated)</label>
<input
type="text"
value={editTags}
onChange={(e) => setEditTags(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Content (Markdown)</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">
Summary
</label>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded h-64 font-mono text-sm bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100"
value={summary}
onChange={(e) => setSummary(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded h-56 bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Preview</label>
<div className="border border-gray-300 rounded p-4 prose max-w-none max-h-64 overflow-auto bg-white dark:bg-slate-950 dark:border-slate-700">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{editContent}
</ReactMarkdown>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">
Digest Date
</label>
<input
type="date"
value={digestDate}
onChange={(e) => setDigestDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">
Tags (comma separated)
</label>
<input
type="text"
value={tagsText}
onChange={(e) => setTagsText(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100"
/>
</div>
</div>
{/* Audio Upload Section */}
<div className="border-t border-gray-200 pt-4 dark:border-slate-800">
<label className="block text-sm font-medium mb-3 text-gray-700 dark:text-slate-200">
🎧 Audio Attachment
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">
Source URL
</label>
<AudioUpload
postId={editingPost.id}
existingAudioUrl={editingPost.audio_url}
onUploadSuccess={(url, duration) => {
setEditingPost({
...editingPost,
audio_url: url,
audio_duration: duration,
});
}}
onDeleteSuccess={() => {
setEditingPost({
...editingPost,
audio_url: null,
audio_duration: null,
});
}}
<input
type="url"
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
placeholder="https://..."
className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100"
/>
</div>
</div>
<div className="p-6 border-t border-gray-200 dark:border-slate-800 flex justify-end gap-2">
<button
onClick={() => setEditingPost(null)}
className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"
>
Cancel
</button>
<button
onClick={handleSaveEdit}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save Changes
</button>
</div>
</div>
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
<div className="p-6 border-b border-gray-200 dark:border-slate-800">
<h3 className="text-lg font-semibold">New Post</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Date</label>
<input
type="text"
value={newDate}
onChange={(e) => setNewDate(e.target.value)}
placeholder="2026-02-22"
className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 placeholder:text-gray-400 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Tags (comma separated)</label>
<input
type="text"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
placeholder="daily-digest, news"
className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 placeholder:text-gray-400 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Content (Markdown)</label>
<textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
placeholder="# Post Title\n\nYour content here..."
className="w-full px-3 py-2 border border-gray-300 rounded h-64 font-mono text-sm bg-white text-gray-900 placeholder:text-gray-400 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
{editing && (
<div className="border-t border-gray-200 pt-4 dark:border-slate-800">
<label className="block text-sm font-medium mb-3 text-gray-700 dark:text-slate-200">
Audio Attachment
</label>
<AudioUpload
postId={editing.id}
existingAudioUrl={editing.audioUrl}
onUploadSuccess={(url, duration) => {
setEditing({
...editing,
audioUrl: url,
audioDuration: duration,
});
}}
onDeleteSuccess={() => {
setEditing({
...editing,
audioUrl: null,
audioDuration: null,
});
}}
/>
</div>
)}
</div>
<div className="p-6 border-t border-gray-200 dark:border-slate-800 flex justify-end gap-2">
<button
onClick={() => setShowCreateModal(false)}
onClick={() => {
setShowCreateModal(false);
setEditing(null);
resetForm();
}}
className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"
>
Cancel
</button>
<button
onClick={handleCreate}
onClick={() => void (editing ? handleSaveEdit() : handleCreate())}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Create Post
{editing ? "Save Changes" : "Create Article"}
</button>
</div>
</div>

View File

@ -0,0 +1,179 @@
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import {
isMissingBlogArticlesTableError,
mapArticleRow,
normalizeTags,
toDigestDate,
toPublishedAt,
type ArticleRow,
} from "@/lib/articles";
import { requireWriteAccess } from "@/lib/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
interface Params {
params: Promise<{ id: string }>;
}
export async function GET(_: Request, { params }: Params) {
try {
const { id } = await params;
const { data, error } = await serviceSupabase
.from("blog_articles")
.select(
"id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration"
)
.eq("id", id)
.single();
if (error || !data) {
return NextResponse.json({ error: "Article not found" }, { status: 404 });
}
return NextResponse.json({ article: mapArticleRow(data as ArticleRow) });
} catch (error) {
console.error("GET /api/articles/:id failed:", error);
if (isMissingBlogArticlesTableError(error)) {
return NextResponse.json(
{
error:
"Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.",
},
{ status: 500 }
);
}
return NextResponse.json({ error: "Failed to fetch article" }, { status: 500 });
}
}
export async function PATCH(request: Request, { params }: Params) {
const allowed = await requireWriteAccess(request);
if (!allowed) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { id } = await params;
const body = (await request.json().catch(() => ({}))) as {
title?: string;
summary?: string;
sourceUrl?: string | null;
digestDate?: string | null;
publishedAt?: string | null;
tags?: string[];
isPublished?: boolean;
};
const updates: Record<string, unknown> = {};
if (typeof body.title === "string") {
const clean = body.title.trim();
if (!clean) {
return NextResponse.json({ error: "title cannot be empty" }, { status: 400 });
}
updates.title = clean;
}
if (typeof body.summary === "string") {
const clean = body.summary.trim();
if (!clean) {
return NextResponse.json({ error: "summary cannot be empty" }, { status: 400 });
}
updates.summary = clean;
}
if (body.sourceUrl !== undefined) {
updates.source_url = typeof body.sourceUrl === "string" && body.sourceUrl.trim()
? body.sourceUrl.trim()
: null;
}
if (body.digestDate !== undefined) {
updates.digest_date = toDigestDate(body.digestDate);
}
if (body.publishedAt !== undefined) {
updates.published_at = toPublishedAt(body.publishedAt);
}
if (body.tags !== undefined) {
updates.tags = normalizeTags(body.tags);
}
if (typeof body.isPublished === "boolean") {
updates.is_published = body.isPublished;
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
}
const { data, error } = await serviceSupabase
.from("blog_articles")
.update(updates)
.eq("id", id)
.select(
"id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration"
)
.single();
if (error || !data) {
return NextResponse.json({ error: "Article not found" }, { status: 404 });
}
return NextResponse.json({ article: mapArticleRow(data as ArticleRow) });
} catch (error) {
console.error("PATCH /api/articles/:id failed:", error);
if (isMissingBlogArticlesTableError(error)) {
return NextResponse.json(
{
error:
"Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.",
},
{ status: 500 }
);
}
return NextResponse.json({ error: "Failed to update article" }, { status: 500 });
}
}
export async function DELETE(request: Request, { params }: Params) {
const allowed = await requireWriteAccess(request);
if (!allowed) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { id } = await params;
const { error } = await serviceSupabase
.from("blog_articles")
.delete()
.eq("id", id);
if (error) {
throw error;
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("DELETE /api/articles/:id failed:", error);
if (isMissingBlogArticlesTableError(error)) {
return NextResponse.json(
{
error:
"Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.",
},
{ status: 500 }
);
}
return NextResponse.json({ error: "Failed to delete article" }, { status: 500 });
}
}

View File

@ -0,0 +1,146 @@
import crypto from "node:crypto";
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import {
isMissingBlogArticlesTableError,
mapArticleRow,
normalizeTags,
toDigestDate,
toPublishedAt,
type ArticleRow,
} from "@/lib/articles";
import { requireWriteAccess } from "@/lib/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
function parseLimit(raw: string | null): number {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
return 100;
}
return Math.min(500, Math.max(1, Math.floor(parsed)));
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const limit = parseLimit(searchParams.get("limit"));
const digestDate = toDigestDate(searchParams.get("digestDate"));
const tag = searchParams.get("tag")?.trim() || null;
const withAudio = searchParams.get("withAudio") === "true";
let query = serviceSupabase
.from("blog_articles")
.select(
"id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration"
)
.eq("is_published", true)
.order("published_at", { ascending: false })
.limit(limit);
if (digestDate) {
query = query.eq("digest_date", digestDate);
}
if (withAudio) {
query = query.not("audio_url", "is", null);
}
if (tag) {
query = query.contains("tags", [tag]);
}
const { data, error } = await query;
if (error) {
throw error;
}
const articles = ((data || []) as ArticleRow[]).map(mapArticleRow);
return NextResponse.json({ articles });
} catch (error) {
console.error("GET /api/articles failed:", error);
if (isMissingBlogArticlesTableError(error)) {
return NextResponse.json(
{
error:
"Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.",
},
{ status: 500 }
);
}
return NextResponse.json({ error: "Failed to load articles" }, { status: 500 });
}
}
export async function POST(request: Request) {
const allowed = await requireWriteAccess(request);
if (!allowed) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = (await request.json().catch(() => ({}))) as {
id?: string;
title?: string;
summary?: string;
sourceUrl?: string | null;
digestDate?: string | null;
publishedAt?: string | null;
tags?: string[];
};
const title = body.title?.trim() || "";
const summary = body.summary?.trim() || "";
if (!title || !summary) {
return NextResponse.json(
{ error: "title and summary are required" },
{ status: 400 }
);
}
const row = {
id: body.id?.trim() || crypto.randomUUID(),
title,
summary,
source_url: body.sourceUrl?.trim() || null,
digest_date: toDigestDate(body.digestDate),
published_at: toPublishedAt(body.publishedAt),
tags: normalizeTags(body.tags),
audio_url: null as string | null,
audio_duration: null as number | null,
is_published: true,
};
const { data, error } = await serviceSupabase
.from("blog_articles")
.insert(row)
.select(
"id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration"
)
.single();
if (error) {
throw error;
}
return NextResponse.json({ article: mapArticleRow(data as ArticleRow) });
} catch (error) {
console.error("POST /api/articles failed:", error);
if (isMissingBlogArticlesTableError(error)) {
return NextResponse.json(
{
error:
"Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.",
},
{ status: 500 }
);
}
return NextResponse.json({ error: "Failed to create article" }, { status: 500 });
}
}

View File

@ -49,8 +49,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Post ID required" }, { status: 400 });
}
const { data, error } = await supabase
.from("blog_messages")
const { data, error } = await serviceSupabase
.from("blog_articles")
.select("id, audio_url, audio_duration")
.eq("id", postId)
.single();
@ -107,9 +107,9 @@ export async function POST(request: Request) {
}
// Get post info for filename
const { data: post, error: postError } = await supabase
.from("blog_messages")
.select("date")
const { data: post, error: postError } = await serviceSupabase
.from("blog_articles")
.select("digest_date, published_at")
.eq("id", postId)
.single();
@ -123,7 +123,9 @@ export async function POST(request: Request) {
// Create filename
const timestamp = Date.now();
const sanitizedDate = post.date.replace(/[^a-zA-Z0-9-]/g, "");
const fallbackDate = String(post.published_at || "").slice(0, 10);
const dateForFile = post.digest_date || fallbackDate || new Date().toISOString().slice(0, 10);
const sanitizedDate = dateForFile.replace(/[^a-zA-Z0-9-]/g, "");
const extension = file.name.split(".").pop() || "mp3";
const filename = `upload-${sanitizedDate}-${postId}-${timestamp}.${extension}`;
@ -165,7 +167,7 @@ export async function POST(request: Request) {
// Update database with audio info
const { error: updateError } = await serviceSupabase
.from("blog_messages")
.from("blog_articles")
.update({
audio_url: urlData.publicUrl,
audio_duration: estimatedDuration,
@ -215,8 +217,8 @@ export async function DELETE(request: Request) {
}
// Get current audio info
const { data: post, error: fetchError } = await supabase
.from("blog_messages")
const { data: post, error: fetchError } = await serviceSupabase
.from("blog_articles")
.select("audio_url")
.eq("id", postId)
.single();
@ -227,17 +229,21 @@ export async function DELETE(request: Request) {
// Extract path from URL and delete from storage
if (post?.audio_url) {
const url = new URL(post.audio_url);
const pathMatch = url.pathname.match(/\/object\/public\/[^/]+\/(.+)/);
if (pathMatch) {
const path = decodeURIComponent(pathMatch[1]);
await serviceSupabase.storage.from(BUCKET_NAME).remove([path]);
try {
const url = new URL(post.audio_url);
const pathMatch = url.pathname.match(/\/object\/public\/[^/]+\/(.+)/);
if (pathMatch) {
const path = decodeURIComponent(pathMatch[1]);
await serviceSupabase.storage.from(BUCKET_NAME).remove([path]);
}
} catch {
// Local-file URLs or non-URL paths are valid in some deployments.
}
}
// Clear audio fields in database
const { error: updateError } = await serviceSupabase
.from("blog_messages")
.from("blog_articles")
.update({
audio_url: null,
audio_duration: null,
@ -259,4 +265,4 @@ export async function DELETE(request: Request) {
{ status: 500 }
);
}
}
}

View File

@ -1,123 +1,117 @@
/**
* Daily Digest API Endpoint
* Saves digest content and optionally generates TTS audio
*/
import crypto from "node:crypto";
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { generateSpeech } from "@/lib/tts";
import { uploadAudio } from "@/lib/storage";
import { extractTitle, extractExcerpt } from "@/lib/podcast";
import {
isMissingBlogArticlesTableError,
normalizeTags,
toDigestDate,
} from "@/lib/articles";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
interface DigestArticleInput {
title?: string;
summary?: string;
sourceUrl?: string | null;
tags?: string[];
publishedAt?: string | null;
}
interface DigestRequestBody {
date?: string;
tags?: string[];
articles?: DigestArticleInput[];
}
export async function POST(request: Request) {
const apiKey = request.headers.get("x-api-key");
const CRON_API_KEY = process.env.CRON_API_KEY;
console.log("API Key from header:", apiKey);
console.log("CRON_API_KEY exists:", !!CRON_API_KEY);
if (!CRON_API_KEY || apiKey !== CRON_API_KEY) {
const cronApiKey = process.env.CRON_API_KEY;
if (!cronApiKey || apiKey !== cronApiKey) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { content, date, tags, generateAudio = true } = await request.json();
if (!content || !date) {
return NextResponse.json({ error: "Content and date required" }, { status: 400 });
}
const id = Date.now().toString();
const newMessage: Record<string, any> = {
id,
date,
content,
timestamp: Date.now(),
tags: tags || ["daily-digest"]
};
// Only add audio fields if they exist in the table
// This handles schema differences between environments
// Save message first (without audio)
const { error } = await serviceSupabase
.from("blog_messages")
.insert(newMessage);
if (error) {
console.error("Error saving digest:", error);
return NextResponse.json({ error: "Failed to save" }, { status: 500 });
}
// Generate TTS audio asynchronously (don't block response)
if (generateAudio && process.env.ENABLE_TTS === "true") {
// Use a non-blocking approach
generateTTSAsync(id, content, date).catch(err => {
console.error("TTS generation failed (async):", err);
});
}
return NextResponse.json({
success: true,
id,
audioGenerated: generateAudio && process.env.ENABLE_TTS === "true"
});
}
/**
* Generate TTS audio and upload to storage
* This runs asynchronously after the main response is sent
*/
async function generateTTSAsync(
id: string,
content: string,
date: string
): Promise<void> {
try {
console.log(`[TTS] Starting generation for digest ${id}...`);
// Generate speech
const { audioBuffer, duration, format } = await generateSpeech(content, {
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai",
voice: process.env.TTS_VOICE || "alloy",
});
const body = (await request.json().catch(() => ({}))) as DigestRequestBody;
const digestDate = toDigestDate(body.date) || new Date().toISOString().slice(0, 10);
const baseTags = normalizeTags(body.tags);
const articles = Array.isArray(body.articles) ? body.articles : [];
// Determine file extension based on format
const ext = format === "audio/wav" ? "wav" :
format === "audio/aiff" ? "aiff" : "mp3";
// Create filename with date for organization
const filename = `digest-${date}-${id}.${ext}`;
// Upload to Supabase Storage
const { url } = await uploadAudio(audioBuffer, filename, format);
// Update database with audio URL
const { error: updateError } = await serviceSupabase
.from("blog_messages")
.update({
audio_url: url,
audio_duration: duration,
})
.eq("id", id);
if (updateError) {
console.error("[TTS] Error updating database:", updateError);
throw updateError;
if (articles.length === 0) {
return NextResponse.json(
{ error: "articles[] is required and must include at least one item" },
{ status: 400 }
);
}
console.log(`[TTS] Successfully generated audio for digest ${id}: ${url}`);
const now = Date.now();
const rows = articles
.map((item, index) => {
const title = item.title?.trim() || "";
const summary = item.summary?.trim() || "";
if (!title || !summary) {
return null;
}
const tags = normalizeTags([...(item.tags || []), ...baseTags, "daily-digest"]);
const publishedAt =
typeof item.publishedAt === "string" && item.publishedAt.trim()
? new Date(item.publishedAt).toISOString()
: new Date(now - index).toISOString();
return {
id: crypto.randomUUID(),
title,
summary,
source_url: item.sourceUrl?.trim() || null,
digest_date: digestDate,
published_at: publishedAt,
tags,
audio_url: null as string | null,
audio_duration: null as number | null,
is_published: true,
};
})
.filter((item): item is NonNullable<typeof item> => !!item);
if (rows.length === 0) {
return NextResponse.json(
{ error: "Each article requires title and summary." },
{ status: 400 }
);
}
const { data, error } = await serviceSupabase
.from("blog_articles")
.insert(rows)
.select("id, title, summary, digest_date, source_url, tags, published_at");
if (error) {
throw error;
}
return NextResponse.json({
success: true,
digestDate,
inserted: rows.length,
articles: data || [],
});
} catch (error) {
console.error(`[TTS] Failed to generate audio for digest ${id}:`, error);
// Don't throw - we don't want to fail the whole request
console.error("POST /api/digest failed:", error);
if (isMissingBlogArticlesTableError(error)) {
return NextResponse.json(
{
error:
"Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.",
},
{ status: 500 }
);
}
return NextResponse.json({ error: "Failed to create digest articles" }, { status: 500 });
}
}

View File

@ -1,123 +1,218 @@
import crypto from "node:crypto";
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import {
articleDateFromRow,
articleLegacyContent,
articleTimestampFromRow,
isMissingBlogArticlesTableError,
normalizeTags,
toDigestDate,
toPublishedAt,
} from "@/lib/articles";
import { requireWriteAccess } from "@/lib/auth";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
function getBearerToken(request: Request): string | null {
const authorization = request.headers.get("authorization");
if (!authorization?.startsWith("Bearer ")) {
return null;
}
const token = authorization.slice("Bearer ".length).trim();
return token.length > 0 ? token : null;
interface LegacyPayload {
title?: string;
summary?: string;
content?: string;
sourceUrl?: string | null;
date?: string | null;
publishedAt?: string | null;
tags?: string[];
}
async function getUserFromRequest(request: Request) {
const token = getBearerToken(request);
if (!token) {
return null;
function parseLegacyContent(raw: string): {
title: string;
summary: string;
sourceUrl: string | null;
} {
const lines = raw.replace(/\r/g, "").split("\n").map((line) => line.trim());
const nonEmpty = lines.filter(Boolean);
const heading = nonEmpty.find((line) => /^#{1,2}\s+/.test(line));
const title = (heading?.replace(/^#{1,2}\s+/, "") || nonEmpty[0] || "").trim();
let sourceUrl: string | null = null;
const summaryLines: string[] = [];
for (const line of nonEmpty) {
if (/^#{1,2}\s+/.test(line)) {
continue;
}
const sourceMatch = line.match(/^source\s*:\s*(https?:\/\/\S+)/i);
if (sourceMatch) {
sourceUrl = sourceMatch[1];
continue;
}
summaryLines.push(line);
}
const { data, error } = await supabase.auth.getUser(token);
if (error) {
console.error("Error validating auth token:", error.message);
return null;
}
return data.user;
return {
title,
summary: summaryLines.join("\n").trim(),
sourceUrl,
};
}
const CRON_API_KEY = process.env.CRON_API_KEY;
// GET is public (read-only)
// Legacy compatibility endpoint. Prefer /api/articles.
export async function GET() {
const { data: messages, error } = await serviceSupabase
.from("blog_messages")
.select("id, date, content, timestamp, tags")
.order("timestamp", { ascending: false });
const { data, error } = await serviceSupabase
.from("blog_articles")
.select(
"id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration"
)
.eq("is_published", true)
.order("published_at", { ascending: false })
.limit(500);
if (error) {
console.error("Error fetching messages:", error);
if (isMissingBlogArticlesTableError(error)) {
return NextResponse.json(
{
error:
"Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.",
},
{ status: 500 }
);
}
return NextResponse.json({ error: "Failed to fetch messages" }, { status: 500 });
}
return NextResponse.json(messages || []);
const rows = (data || []).map((item) => ({
id: item.id,
date: articleDateFromRow(item),
timestamp: articleTimestampFromRow(item),
tags: item.tags || [],
title: item.title,
summary: item.summary,
source_url: item.source_url,
sourceUrl: item.source_url,
audio_url: item.audio_url,
audio_duration: item.audio_duration,
content: articleLegacyContent({
title: item.title,
summary: item.summary,
sourceUrl: item.source_url,
}),
}));
return NextResponse.json(rows);
}
// POST requires auth OR API key for cron jobs
export async function POST(request: Request) {
const apiKey = request.headers.get("x-api-key");
const hasCronAccess = !!CRON_API_KEY && apiKey === CRON_API_KEY;
if (!hasCronAccess) {
const user = await getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const allowed = await requireWriteAccess(request);
if (!allowed) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { content, date, tags } = await request.json();
if (!content || !date) {
return NextResponse.json({ error: "Content and date required" }, { status: 400 });
const body = (await request.json().catch(() => ({}))) as LegacyPayload;
let title = body.title?.trim() || "";
let summary = body.summary?.trim() || "";
let sourceUrl = body.sourceUrl?.trim() || null;
if ((!title || !summary) && typeof body.content === "string" && body.content.trim()) {
const parsed = parseLegacyContent(body.content);
title = title || parsed.title;
summary = summary || parsed.summary;
sourceUrl = sourceUrl || parsed.sourceUrl;
}
const newMessage = {
id: Date.now().toString(),
date,
content,
timestamp: Date.now(),
tags: tags || [],
if (!title || !summary) {
return NextResponse.json(
{ error: "title and summary required (or content in legacy mode)" },
{ status: 400 }
);
}
const row = {
id: crypto.randomUUID(),
title,
summary,
source_url: sourceUrl,
digest_date: toDigestDate(body.date),
published_at: toPublishedAt(body.publishedAt),
tags: normalizeTags(body.tags),
audio_url: null as string | null,
audio_duration: null as number | null,
is_published: true,
};
const { error } = await serviceSupabase
.from("blog_messages")
.insert(newMessage);
const { data, error } = await serviceSupabase
.from("blog_articles")
.insert(row)
.select(
"id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration"
)
.single();
if (error) {
console.error("Error saving message:", error);
if (isMissingBlogArticlesTableError(error)) {
return NextResponse.json(
{
error:
"Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.",
},
{ status: 500 }
);
}
return NextResponse.json({ error: "Failed to save message" }, { status: 500 });
}
return NextResponse.json(newMessage);
const result = {
id: data.id,
date: articleDateFromRow(data),
timestamp: articleTimestampFromRow(data),
tags: data.tags || [],
title: data.title,
summary: data.summary,
source_url: data.source_url,
sourceUrl: data.source_url,
audio_url: data.audio_url,
audio_duration: data.audio_duration,
content: articleLegacyContent({
title: data.title,
summary: data.summary,
sourceUrl: data.source_url,
}),
};
return NextResponse.json(result);
}
// DELETE requires auth OR API key
export async function DELETE(request: Request) {
const apiKey = request.headers.get("x-api-key");
const hasCronAccess = !!CRON_API_KEY && apiKey === CRON_API_KEY;
if (!hasCronAccess) {
const user = await getUserFromRequest(request);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const allowed = await requireWriteAccess(request);
if (!allowed) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await request.json();
const body = (await request.json().catch(() => ({}))) as { id?: string };
const id = body.id?.trim();
if (!id) {
return NextResponse.json({ error: "ID required" }, { status: 400 });
}
const { error } = await serviceSupabase
.from("blog_messages")
.delete()
.eq("id", id);
const { error } = await serviceSupabase.from("blog_articles").delete().eq("id", id);
if (error) {
console.error("Error deleting message:", error);
if (isMissingBlogArticlesTableError(error)) {
return NextResponse.json(
{
error:
"Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.",
},
{ status: 500 }
);
}
return NextResponse.json({ error: "Failed to delete message" }, { status: 500 });
}
return NextResponse.json({ success: true });
}

View File

@ -6,6 +6,7 @@
import { NextResponse } from "next/server";
import { fetchEpisodes, generateRSS, DEFAULT_CONFIG } from "@/lib/podcast";
import { isMissingBlogArticlesTableError } from "@/lib/articles";
export const dynamic = "force-dynamic"; // Always generate fresh RSS
@ -26,13 +27,16 @@ export async function GET() {
});
} catch (error) {
console.error("Error generating RSS feed:", error);
const description = isMissingBlogArticlesTableError(error)
? "Missing table public.blog_articles. Apply migration 20260303_create_blog_articles.sql."
: "Error loading podcast feed. Please try again later.";
return new NextResponse(
`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>OpenClaw Daily Digest</title>
<description>Error loading podcast feed. Please try again later.</description>
<description>${description}</description>
</channel>
</rss>`,
{

View File

@ -1,16 +1,18 @@
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { extractExcerpt, extractTitle } from "@/lib/podcast";
import {
isMissingBlogArticlesTableError,
mapArticleRow,
type ArticleRow,
} from "@/lib/articles";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface DigestRow {
id: string;
date: string | null;
content: string;
timestamp: number | string;
tags: string[] | null;
}
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
function escapeXml(input: string): string {
return input
@ -25,101 +27,69 @@ function cdataSafe(input: string): string {
return input.replaceAll("]]>", "]]]]><![CDATA[>");
}
function asRfc2822(value: string | number | null | undefined): string {
if (typeof value === "number") {
return new Date(value).toUTCString();
function articleContentEncoded(summary: string, sourceUrl: string | null): string {
const lines = [summary];
if (sourceUrl) {
lines.push(`Original source: ${sourceUrl}`);
}
if (typeof value === "string" && value.trim().length > 0) {
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return new Date(numeric).toUTCString();
}
return new Date(value).toUTCString();
}
return new Date().toUTCString();
}
function buildErrorFeed(baseUrl: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>OpenClaw Daily Digest</title>
<link>${escapeXml(baseUrl)}</link>
<description>Error loading digest RSS feed. Please try again later.</description>
</channel>
</rss>`;
return lines.join("\n\n");
}
export async function GET(request: Request) {
const url = new URL(request.url);
const baseUrl =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
`${url.protocol}//${url.host}`;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey =
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseKey) {
return new NextResponse(buildErrorFeed(baseUrl), {
status: 500,
headers: {
"Content-Type": "application/xml; charset=utf-8",
},
});
}
try {
const supabase = createClient(supabaseUrl, supabaseKey);
const url = new URL(request.url);
const baseUrl =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
`${url.protocol}//${url.host}`;
const { data, error } = await supabase
.from("blog_messages")
.select("id, date, content, timestamp, tags")
.order("timestamp", { ascending: false })
.limit(100);
const { data, error } = await serviceSupabase
.from("blog_articles")
.select("id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration")
.eq("is_published", true)
.order("published_at", { ascending: false })
.limit(150);
if (error) {
throw error;
}
const rows = (data || []) as DigestRow[];
const itemsXml = rows
.map((row) => {
const title = escapeXml(extractTitle(row.content || "Daily Digest"));
const excerpt = escapeXml(extractExcerpt(row.content || "", 420));
const pubDate = asRfc2822(row.timestamp || row.date);
const postUrl = `${baseUrl}/?post=${row.id}`;
const guid = `openclaw-digest-${row.id}`;
const categories = (row.tags || [])
const articles = ((data || []) as ArticleRow[]).map(mapArticleRow);
const itemsXml = articles
.map((article) => {
const link = article.sourceUrl || `${baseUrl}/?post=${article.id}`;
const categories = article.tags
.map((tag) => `<category>${escapeXml(tag)}</category>`)
.join("");
return `<item>
<title>${title}</title>
<link>${escapeXml(postUrl)}</link>
<guid isPermaLink="false">${escapeXml(guid)}</guid>
<pubDate>${pubDate}</pubDate>
<description>${excerpt}</description>
<content:encoded><![CDATA[${cdataSafe(row.content || "")}]]></content:encoded>
<title>${escapeXml(article.title)}</title>
<link>${escapeXml(link)}</link>
<guid isPermaLink="false">${escapeXml(`openclaw-article-${article.id}`)}</guid>
<pubDate>${new Date(article.publishedAt).toUTCString()}</pubDate>
<description>${escapeXml(article.summary)}</description>
<content:encoded><![CDATA[${cdataSafe(
articleContentEncoded(article.summary, article.sourceUrl)
)}]]></content:encoded>
${categories}
</item>`;
})
.join("\n");
const lastBuildDate =
rows.length > 0 ? asRfc2822(rows[0].timestamp || rows[0].date) : new Date().toUTCString();
articles.length > 0
? new Date(articles[0].publishedAt).toUTCString()
: new Date().toUTCString();
const feedXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/">
<channel>
<title>OpenClaw Daily Digest</title>
<link>${escapeXml(baseUrl)}</link>
<description>Daily digest posts from OpenClaw Blog Backup.</description>
<description>Daily article feed. One RSS item per article.</description>
<language>en-us</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<sy:updatePeriod>hourly</sy:updatePeriod>
<sy:updateFrequency>1</sy:updateFrequency>
<atom:link href="${escapeXml(`${baseUrl}/api/rss`)}" rel="self" type="application/rss+xml"/>
${itemsXml}
</channel>
@ -132,12 +102,18 @@ export async function GET(request: Request) {
},
});
} catch (error) {
console.error("Error generating digest RSS feed:", error);
return new NextResponse(buildErrorFeed(baseUrl), {
status: 500,
headers: {
"Content-Type": "application/xml; charset=utf-8",
},
});
console.error("GET /api/rss failed:", error);
const description = isMissingBlogArticlesTableError(error)
? "Missing table public.blog_articles. Apply migration 20260303_create_blog_articles.sql."
: "Error loading feed.";
return new NextResponse(
`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>OpenClaw Daily Digest</title><description>${escapeXml(
description
)}</description></channel></rss>`,
{
status: 500,
headers: { "Content-Type": "application/xml; charset=utf-8" },
}
);
}
}

View File

@ -4,7 +4,7 @@
*/
import { NextResponse } from "next/server";
import { generateSpeech, TTSOptions } from "@/lib/tts";
import { generateSpeech, mixWithMusicLayered } from "@/lib/tts";
import { uploadAudio } from "@/lib/storage";
import { createClient } from "@supabase/supabase-js";
@ -28,18 +28,35 @@ export async function POST(request: Request) {
}
try {
const { text, postId, options } = await request.json();
const body = await request.json();
const { text, postId, includeMusic, options } = body;
if (!text) {
return NextResponse.json({ error: "Text content is required" }, { status: 400 });
}
// Generate TTS audio
const { audioBuffer, duration, format } = await generateSpeech(text, {
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai",
const ttsOptions = {
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay" | "kokoro") || "openai",
voice: process.env.TTS_VOICE || "alloy",
...options,
});
};
let ttsResult = await generateSpeech(text, ttsOptions);
// Mix with music if enabled
// Check includeMusic at top level OR in options
const musicEnabled = includeMusic ?? options?.includeMusic ?? process.env.ENABLE_PODCAST_MUSIC === "true";
console.log("[TTS] includeMusic:", includeMusic, "options.includeMusic:", options?.includeMusic, "ENABLE_PODCAST_MUSIC:", process.env.ENABLE_PODCAST_MUSIC);
if (musicEnabled) {
console.log("[TTS] Mixing with intro/outro music...");
ttsResult = await mixWithMusicLayered(ttsResult, {
introUrl: options?.introUrl,
});
console.log("[TTS] Music mixing complete");
}
const { audioBuffer, duration, format } = ttsResult;
// Determine file extension based on format
const ext = format === "audio/wav" ? "wav" :
@ -57,7 +74,7 @@ export async function POST(request: Request) {
// If postId provided, update the blog post with audio URL
if (postId) {
const { error: updateError } = await serviceSupabase
.from("blog_messages")
.from("blog_articles")
.update({
audio_url: url,
audio_duration: duration,
@ -100,7 +117,7 @@ export async function GET(request: Request) {
}
const { data, error } = await serviceSupabase
.from("blog_messages")
.from("blog_articles")
.select("id, audio_url, audio_duration")
.eq("id", postId)
.single();

View File

@ -1,32 +1,52 @@
"use client";
import { Suspense, useState, useEffect, useMemo } from "react";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { format } from "date-fns";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import Head from "next/head";
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { AudioPlayer } from "@/components/AudioPlayer";
// Helper to parse date string (YYYY-MM-DD) correctly across timezones
// Appends noon UTC to ensure consistent date display regardless of local timezone
function parseDate(dateStr: string): Date {
return new Date(`${dateStr}T12:00:00Z`);
}
interface Message {
id: string;
date: string;
content: string;
timestamp: number;
tags?: string[];
audio_url?: string;
audio_duration?: number;
}
type Theme = "light" | "dark";
interface Article {
id: string;
title: string;
summary: string;
sourceUrl: string | null;
digestDate: string | null;
publishedAt: string;
tags: string[];
audioUrl: string | null;
audioDuration: number | null;
}
function parseDate(raw: string): Date {
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
return new Date(`${raw}T12:00:00Z`);
}
return new Date(raw);
}
function displayDate(article: Article): string {
return article.digestDate || article.publishedAt.slice(0, 10);
}
function plainExcerpt(input: string, maxLength = 180): string {
const text = input
.replace(/(\*\*|__|\*|_)/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/\n+/g, " ")
.trim();
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, maxLength).trim()}...`;
}
export default function BlogPage() {
return (
<Suspense
@ -46,14 +66,14 @@ function BlogPageContent() {
const searchParams = useSearchParams();
const selectedTag = searchParams?.get("tag") ?? null;
const selectedPostId = searchParams?.get("post") ?? null;
const [messages, setMessages] = useState<Message[]>([]);
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [theme, setTheme] = useState<Theme>("light");
useEffect(() => {
fetchMessages();
void fetchArticles();
}, []);
useEffect(() => {
@ -72,73 +92,60 @@ function BlogPageContent() {
localStorage.setItem("theme", theme);
}, [theme]);
async function fetchMessages() {
async function fetchArticles() {
try {
const res = await fetch("/api/messages");
const data = await res.json();
setMessages(data);
const res = await fetch("/api/articles?limit=300");
const payload = (await res.json()) as { articles?: Article[] };
setArticles(Array.isArray(payload.articles) ? payload.articles : []);
} catch (err) {
console.error("Failed to fetch:", err);
console.error("Failed to fetch articles:", err);
setArticles([]);
} finally {
setLoading(false);
}
}
// Get all unique tags
const allTags = useMemo(() => {
const tags = new Set<string>();
messages.forEach((m) => m.tags?.forEach((t) => tags.add(t)));
return Array.from(tags).sort();
}, [messages]);
// Filter messages
const filteredMessages = useMemo(() => {
let filtered = messages;
if (selectedTag) {
filtered = filtered.filter((m) => m.tags?.includes(selectedTag));
for (const article of articles) {
for (const tag of article.tags || []) {
tags.add(tag);
}
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(m) =>
m.content.toLowerCase().includes(query) ||
m.tags?.some((t) => t.toLowerCase().includes(query))
return [...tags].sort();
}, [articles]);
const filteredArticles = useMemo(() => {
let list = [...articles];
if (selectedTag) {
list = list.filter((item) => item.tags?.includes(selectedTag));
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
list = list.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
item.summary.toLowerCase().includes(q) ||
item.tags.some((tag) => tag.toLowerCase().includes(q))
);
}
return filtered.sort((a, b) => b.timestamp - a.timestamp);
}, [messages, selectedTag, searchQuery]);
// Get featured post (most recent)
const featuredPost = filteredMessages[0];
const regularPosts = filteredMessages.slice(1);
return list.sort(
(a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
);
}, [articles, selectedTag, searchQuery]);
const selectedPost = useMemo(
() =>
selectedPostId
? messages.find((message) => message.id === selectedPostId) ?? null
: null,
[messages, selectedPostId]
() => (selectedPostId ? articles.find((item) => item.id === selectedPostId) || null : null),
[articles, selectedPostId]
);
// Parse title from content
function getTitle(content: string): string {
const lines = content.split("\n");
const titleLine = lines.find((l) => l.startsWith("# ") || l.startsWith("## "));
return titleLine?.replace(/#{1,2}\s/, "") || "Daily Update";
}
// Get excerpt
function getExcerpt(content: string, maxLength: number = 150): string {
const plainText = content
.replace(/#{1,6}\s/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/\*/g, "")
.replace(/\n/g, " ");
if (plainText.length <= maxLength) return plainText;
return plainText.substring(0, maxLength).trim() + "...";
}
const featuredPost = !selectedTag && !searchQuery ? filteredArticles[0] : null;
const regularPosts = featuredPost
? filteredArticles.filter((item) => item.id !== featuredPost.id)
: filteredArticles;
if (loading) {
return (
@ -152,11 +159,13 @@ function BlogPageContent() {
<>
<Head>
<title>Daily Digest | OpenClaw Blog</title>
<meta name="description" content="AI-powered daily digests on iOS, AI coding assistants, and indie hacking" />
<meta
name="description"
content="AI-powered daily digests on iOS, AI coding assistants, and indie hacking"
/>
</Head>
<div className="min-h-screen bg-white text-gray-900 dark:bg-slate-950 dark:text-slate-100">
{/* Header */}
<header className="border-b border-gray-200 sticky top-0 bg-white/95 backdrop-blur z-50 dark:border-slate-800 dark:bg-slate-950/95">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
@ -164,27 +173,28 @@ function BlogPageContent() {
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white font-bold">
📓
</div>
<span className="font-bold text-xl text-gray-900 dark:text-slate-100">Daily Digest</span>
<span className="font-bold text-xl text-gray-900 dark:text-slate-100">
Daily Digest
</span>
</Link>
<nav className="flex items-center gap-4 md:gap-6">
<Link href="/podcast" className="text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">
<Link
href="/podcast"
className="text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
🎧 Podcast
</Link>
<Link href="https://gantt-board.twisteddevices.com" className="hidden md:inline text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100">
Tasks
</Link>
<Link href="https://mission-control.twisteddevices.com" className="hidden md:inline text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100">
Mission Control
</Link>
<Link href="/admin" className="text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100">
<Link
href="/admin"
className="text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100"
>
Admin
</Link>
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="px-3 py-1.5 rounded-lg border border-gray-300 text-xs font-medium text-gray-700 hover:bg-gray-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
>
{theme === "dark" ? "Light mode" : "Dark mode"}
</button>
@ -195,7 +205,6 @@ function BlogPageContent() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Content */}
<div className="lg:col-span-8">
{selectedPostId ? (
selectedPost ? (
@ -208,7 +217,7 @@ function BlogPageContent() {
</button>
<header className="mb-8">
{selectedPost.tags && selectedPost.tags.length > 0 && (
{selectedPost.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{selectedPost.tags.map((tag) => (
<button
@ -221,36 +230,50 @@ function BlogPageContent() {
))}
</div>
)}
<h1 className="text-3xl font-bold text-gray-900 dark:text-slate-100 mb-3">
{getTitle(selectedPost.content)}
{selectedPost.title}
</h1>
<p className="text-sm text-gray-500 dark:text-slate-400">
{format(parseDate(selectedPost.date), "MMMM d, yyyy")}
{format(parseDate(displayDate(selectedPost)), "MMMM d, yyyy")}
</p>
{selectedPost.sourceUrl && (
<p className="mt-2 text-sm">
<a
href={selectedPost.sourceUrl}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
Open original article
</a>
</p>
)}
</header>
{/* Audio Player */}
{selectedPost.audio_url && (
{selectedPost.audioUrl && (
<div className="mb-8">
<div className="flex items-center gap-3 mb-3">
<span className="text-sm font-medium text-gray-700 dark:text-slate-300">🎧 Listen to this post</span>
<Link
<span className="text-sm font-medium text-gray-700 dark:text-slate-300">
🎧 Listen to this post
</span>
<Link
href="/podcast"
className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
View all episodes
</Link>
</div>
<AudioPlayer
url={selectedPost.audio_url}
duration={selectedPost.audio_duration || undefined}
<AudioPlayer
url={selectedPost.audioUrl}
duration={selectedPost.audioDuration || undefined}
/>
</div>
)}
<div className="markdown-content">
<div className="markdown-content prose prose-gray max-w-none dark:prose-invert">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{selectedPost.content}
{selectedPost.summary}
</ReactMarkdown>
</div>
</article>
@ -267,138 +290,141 @@ function BlogPageContent() {
)
) : (
<>
{/* Page Title */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-slate-100 mb-2">
{selectedTag ? `Posts tagged "${selectedTag}"` : "Latest Posts"}
</h1>
<p className="text-gray-600 dark:text-slate-300">
{filteredMessages.length} post{filteredMessages.length !== 1 ? "s" : ""}
{selectedTag && (
<button
onClick={() => router.push("/")}
className="ml-4 text-blue-600 hover:text-blue-800 text-sm dark:text-blue-400 dark:hover:text-blue-300"
>
Clear filter
</button>
)}
</p>
</div>
{/* Featured Post */}
{featuredPost && !selectedTag && !searchQuery && (
<article className="mb-10">
<Link href={`/?post=${featuredPost.id}`} className="group block">
<div className="relative bg-gray-50 rounded-2xl overflow-hidden border border-gray-200 hover:border-blue-300 transition-colors dark:bg-slate-900 dark:border-slate-800 dark:hover:border-blue-500">
<div className="p-6 md:p-8">
{featuredPost.tags && featuredPost.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{featuredPost.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2.5 py-0.5 rounded-full bg-blue-100 text-blue-700 text-xs font-medium dark:bg-blue-900/40 dark:text-blue-200"
>
{tag}
</span>
))}
</div>
)}
<span className="text-sm text-blue-600 font-medium mb-2 block dark:text-blue-400">
Featured Post
</span>
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-slate-100 mb-3 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{getTitle(featuredPost.content)}
</h2>
<p className="text-gray-600 dark:text-slate-300 mb-4 line-clamp-3">
{getExcerpt(featuredPost.content, 200)}
</p>
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-slate-400">
<span>{format(parseDate(featuredPost.date), "MMMM d, yyyy")}</span>
<span>·</span>
<span>5 min read</span>
</div>
</div>
</div>
</Link>
</article>
)}
{/* Post List */}
<div className="space-y-6">
{regularPosts.map((post) => (
<article
key={post.id}
className="flex gap-4 md:gap-6 p-4 rounded-xl hover:bg-gray-50 transition-colors border border-transparent hover:border-gray-200 dark:hover:bg-slate-900 dark:hover:border-slate-700"
>
{/* Date Column */}
<div className="hidden sm:block text-center min-w-[60px]">
<div className="text-2xl font-bold text-gray-900 dark:text-slate-100">
{format(parseDate(post.date), "d")}
</div>
<div className="text-xs text-gray-500 dark:text-slate-400 uppercase">
{format(parseDate(post.date), "MMM")}
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{post.tags.slice(0, 2).map((tag) => (
<button
key={tag}
onClick={() => router.push(`/?tag=${tag}`)}
className="text-xs text-blue-600 hover:text-blue-800 font-medium dark:text-blue-400 dark:hover:text-blue-300"
>
#{tag}
</button>
))}
</div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-slate-100 mb-2">
{selectedTag ? `Posts tagged "${selectedTag}"` : "Latest Posts"}
</h1>
<p className="text-gray-600 dark:text-slate-300">
{filteredArticles.length} post
{filteredArticles.length !== 1 ? "s" : ""}
{selectedTag && (
<button
onClick={() => router.push("/")}
className="ml-4 text-blue-600 hover:text-blue-800 text-sm dark:text-blue-400 dark:hover:text-blue-300"
>
Clear filter
</button>
)}
<h3 className="text-lg md:text-xl font-semibold text-gray-900 dark:text-slate-100 mb-2 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
<Link href={`/?post=${post.id}`}>
{getTitle(post.content)}
</Link>
</h3>
<p className="text-gray-600 dark:text-slate-300 text-sm line-clamp-2 mb-3">
{getExcerpt(post.content)}
</p>
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-slate-400">
<span>{format(parseDate(post.date), "MMMM d, yyyy")}</span>
<span>·</span>
<span>3 min read</span>
</div>
</div>
</article>
))}
</div>
</p>
</div>
{filteredMessages.length === 0 && (
<div className="text-center py-16">
<p className="text-gray-500 dark:text-slate-400">No posts found.</p>
{(selectedTag || searchQuery) && (
<button
onClick={() => {
router.push("/");
setSearchQuery("");
}}
className="mt-2 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
Clear filters
</button>
{featuredPost && (
<article className="mb-10">
<Link href={`/?post=${featuredPost.id}`} className="group block">
<div className="relative bg-gray-50 rounded-2xl overflow-hidden border border-gray-200 hover:border-blue-300 transition-colors dark:bg-slate-900 dark:border-slate-800 dark:hover:border-blue-500">
<div className="p-6 md:p-8">
{featuredPost.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{featuredPost.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2.5 py-0.5 rounded-full bg-blue-100 text-blue-700 text-xs font-medium dark:bg-blue-900/40 dark:text-blue-200"
>
{tag}
</span>
))}
</div>
)}
<span className="text-sm text-blue-600 font-medium mb-2 block dark:text-blue-400">
Featured Post
</span>
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-slate-100 mb-3 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{featuredPost.title}
</h2>
<p className="text-gray-600 dark:text-slate-300 mb-4 line-clamp-3">
{plainExcerpt(featuredPost.summary, 220)}
</p>
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-slate-400">
<span>{format(parseDate(displayDate(featuredPost)), "MMMM d, yyyy")}</span>
<span>·</span>
<span>{Math.max(1, Math.ceil(featuredPost.summary.length / 700))} min read</span>
</div>
</div>
</div>
</Link>
</article>
)}
<div className="space-y-6">
{regularPosts.map((post) => (
<article
key={post.id}
className="flex gap-4 md:gap-6 p-4 rounded-xl hover:bg-gray-50 transition-colors border border-transparent hover:border-gray-200 dark:hover:bg-slate-900 dark:hover:border-slate-700"
>
<div className="hidden sm:block text-center min-w-[60px]">
<div className="text-2xl font-bold text-gray-900 dark:text-slate-100">
{format(parseDate(displayDate(post)), "d")}
</div>
<div className="text-xs text-gray-500 dark:text-slate-400 uppercase">
{format(parseDate(displayDate(post)), "MMM")}
</div>
</div>
<div className="flex-1 min-w-0">
{post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{post.tags.slice(0, 3).map((tag) => (
<button
key={tag}
onClick={() => router.push(`/?tag=${tag}`)}
className="text-xs text-blue-600 hover:text-blue-800 font-medium dark:text-blue-400 dark:hover:text-blue-300"
>
#{tag}
</button>
))}
</div>
)}
<h3 className="text-lg md:text-xl font-semibold text-gray-900 dark:text-slate-100 mb-2 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
<Link href={`/?post=${post.id}`}>{post.title}</Link>
</h3>
<p className="text-gray-600 dark:text-slate-300 text-sm line-clamp-2 mb-3">
{plainExcerpt(post.summary)}
</p>
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-slate-400">
<span>{format(parseDate(displayDate(post)), "MMMM d, yyyy")}</span>
{post.sourceUrl && (
<>
<span>·</span>
<a
href={post.sourceUrl}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
Source
</a>
</>
)}
</div>
</div>
</article>
))}
</div>
{filteredArticles.length === 0 && (
<div className="text-center py-16">
<p className="text-gray-500 dark:text-slate-400">No posts found.</p>
{(selectedTag || searchQuery) && (
<button
onClick={() => {
router.push("/");
setSearchQuery("");
}}
className="mt-2 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
Clear filters
</button>
)}
</div>
)}
</div>
)}
</>
)}
</div>
{/* Sidebar */}
<div className="lg:col-span-4 space-y-6">
{/* Search */}
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">
Search
@ -412,16 +438,13 @@ function BlogPageContent() {
/>
</div>
{/* Tags */}
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-3">Tags</h3>
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => (
<button
key={tag}
onClick={() =>
router.push(selectedTag === tag ? "/" : `/?tag=${tag}`)
}
onClick={() => router.push(selectedTag === tag ? "/" : `/?tag=${tag}`)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
selectedTag === tag
? "bg-blue-600 text-white"
@ -434,22 +457,19 @@ function BlogPageContent() {
</div>
</div>
{/* About */}
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-2">About</h3>
<p className="text-sm text-gray-600 dark:text-slate-300">
Daily curated digests covering AI coding assistants, iOS development,
OpenClaw updates, and digital entrepreneurship.
One article per row, grouped by digest date for daily publishing and RSS delivery.
</p>
</div>
{/* Stats */}
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-3">Stats</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-slate-300">Total posts</span>
<span className="font-medium dark:text-slate-100">{messages.length}</span>
<span className="font-medium dark:text-slate-100">{articles.length}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-slate-300">Tags</span>
@ -461,7 +481,6 @@ function BlogPageContent() {
</div>
</div>
{/* Footer */}
<footer className="border-t border-gray-200 dark:border-slate-800 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<p className="text-center text-gray-500 dark:text-slate-400 text-sm">

View File

@ -1,10 +1,10 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import Head from "next/head";
import Link from "next/link";
import { format } from "date-fns";
import { PodcastEpisode, DEFAULT_CONFIG } from "@/lib/podcast";
import { DEFAULT_CONFIG, type PodcastEpisode } from "@/lib/podcast";
interface EpisodeWithAudio extends PodcastEpisode {
audioUrl: string;
@ -17,6 +17,13 @@ function formatDuration(seconds: number): string {
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function toDate(value: string): Date {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return new Date(`${value}T12:00:00Z`);
}
return new Date(value);
}
export default function PodcastPage() {
const [episodes, setEpisodes] = useState<EpisodeWithAudio[]>([]);
const [loading, setLoading] = useState(true);
@ -27,36 +34,45 @@ export default function PodcastPage() {
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
fetchEpisodes();
void fetchEpisodes();
}, []);
useEffect(() => {
if (currentEpisode && audioRef.current) {
audioRef.current.play();
void audioRef.current.play();
setIsPlaying(true);
}
}, [currentEpisode]);
async function fetchEpisodes() {
try {
const res = await fetch("/api/messages");
const data = await res.json();
// Filter to only episodes with audio
const episodesWithAudio = (data || [])
.filter((m: any) => m.audio_url)
.map((m: any) => ({
id: m.id,
title: extractTitle(m.content),
description: extractExcerpt(m.content),
content: m.content,
date: m.date,
timestamp: m.timestamp,
audioUrl: m.audio_url,
audioDuration: m.audio_duration || 300,
tags: m.tags || [],
const res = await fetch("/api/articles?withAudio=true&limit=200");
const payload = (await res.json()) as {
articles?: Array<{
id: string;
title: string;
summary: string;
digestDate: string | null;
publishedAt: string;
audioUrl: string | null;
audioDuration: number | null;
tags: string[];
}>;
};
const episodesWithAudio = (payload.articles || [])
.filter((item) => !!item.audioUrl)
.map((item) => ({
id: item.id,
title: item.title,
description: item.summary,
date: item.digestDate || item.publishedAt,
timestamp: new Date(item.publishedAt).getTime(),
audioUrl: item.audioUrl as string,
audioDuration: item.audioDuration || 300,
tags: item.tags || [],
}))
.sort((a: any, b: any) => b.timestamp - a.timestamp);
.sort((a, b) => b.timestamp - a.timestamp);
setEpisodes(episodesWithAudio);
} catch (err) {
@ -66,26 +82,6 @@ export default function PodcastPage() {
}
}
function extractTitle(content: string): string {
const lines = content.split("\n");
const titleLine = lines.find((l) => l.startsWith("# ") || l.startsWith("## "));
return titleLine?.replace(/#{1,2}\s/, "").trim() || "Daily Digest";
}
function extractExcerpt(content: string, maxLength: number = 150): string {
const plainText = content
.replace(/#{1,6}\s/g, "")
.replace(/(\*\*|__|\*|_)/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/```[\s\S]*?```/g, "")
.replace(/`([^`]+)`/g, " $1 ")
.replace(/\n+/g, " ")
.trim();
if (plainText.length <= maxLength) return plainText;
return plainText.substring(0, maxLength).trim() + "...";
}
function handlePlay(episode: EpisodeWithAudio) {
if (currentEpisode?.id === episode.id) {
togglePlay();
@ -96,29 +92,32 @@ export default function PodcastPage() {
}
function togglePlay() {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
if (!audioRef.current) {
return;
}
if (isPlaying) {
audioRef.current.pause();
} else {
void audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
function handleTimeUpdate() {
if (audioRef.current) {
setProgress(audioRef.current.currentTime);
setDuration(audioRef.current.duration || currentEpisode?.audioDuration || 0);
if (!audioRef.current) {
return;
}
setProgress(audioRef.current.currentTime);
setDuration(audioRef.current.duration || currentEpisode?.audioDuration || 0);
}
function handleSeek(e: React.ChangeEvent<HTMLInputElement>) {
const newTime = parseFloat(e.target.value);
if (audioRef.current) {
audioRef.current.currentTime = newTime;
setProgress(newTime);
if (!audioRef.current) {
return;
}
audioRef.current.currentTime = newTime;
setProgress(newTime);
}
function handleEnded() {
@ -127,9 +126,8 @@ export default function PodcastPage() {
}
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
"https://blog-backup-two.vercel.app";
const rssUrl = `${siteUrl}/api/rss`;
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") || "https://blog.twisteddevices.com";
const rssUrl = `${siteUrl}/api/podcast/rss`;
return (
<>
@ -139,7 +137,6 @@ export default function PodcastPage() {
</Head>
<div className="min-h-screen bg-white text-gray-900 dark:bg-slate-950 dark:text-slate-100">
{/* Header */}
<header className="border-b border-gray-200 sticky top-0 bg-white/95 backdrop-blur z-50 dark:border-slate-800 dark:bg-slate-950/95">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
@ -149,12 +146,18 @@ export default function PodcastPage() {
</div>
<span className="font-bold text-xl">Daily Digest Podcast</span>
</Link>
<nav className="flex items-center gap-4">
<Link href="/" className="text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100">
<Link
href="/"
className="text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100"
>
Blog
</Link>
<Link href="/admin" className="text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100">
<Link
href="/admin"
className="text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100"
>
Admin
</Link>
</nav>
@ -163,8 +166,7 @@ export default function PodcastPage() {
</header>
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Podcast Header */}
<div className="bg-gradient-to-br from-blue-600 to-purple-600 rounded-2xl p-8 text-white mb-8">
<div className="bg-gradient-to-br from-blue-600 to-cyan-600 rounded-2xl p-8 text-white mb-8">
<div className="flex flex-col md:flex-row gap-6">
<div className="w-32 h-32 bg-white/20 rounded-xl flex items-center justify-center text-5xl">
🎙
@ -173,39 +175,19 @@ export default function PodcastPage() {
<h1 className="text-3xl font-bold mb-2">{DEFAULT_CONFIG.title}</h1>
<p className="text-blue-100 mb-4">{DEFAULT_CONFIG.description}</p>
<div className="flex flex-wrap gap-3">
<a
<a
href={rssUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6.503 20.752c0 2.07-1.678 3.748-3.75 3.748S-.997 22.82-.997 20.75c0-2.07 1.68-3.748 3.75-3.748s3.753 1.678 3.753 3.748zm10.5-10.12c0 2.07-1.678 3.75-3.75 3.75s-3.75-1.68-3.75-3.75c0-2.07 1.678-3.75 3.75-3.75s3.75 1.68 3.75 3.75zm-1.5 0c0-1.24-1.01-2.25-2.25-2.25s-2.25 1.01-2.25 2.25 1.01 2.25 2.25 2.25 2.25-1.01 2.25-2.25zm4.5 10.12c0 2.07-1.678 3.748-3.75 3.748s-3.75-1.678-3.75-3.748c0-2.07 1.678-3.748 3.75-3.748s3.75 1.678 3.75 3.748zm1.5 0c0-2.898-2.355-5.25-5.25-5.25S15 17.852 15 20.75c0 2.898 2.355 5.25 5.25 5.25s5.25-2.352 5.25-5.25zm-7.5-10.12c0 2.898-2.355 5.25-5.25 5.25S3 13.61 3 10.713c0-2.9 2.355-5.25 5.25-5.25s5.25 2.35 5.25 5.25zm1.5 0c0-3.73-3.02-6.75-6.75-6.75S-3 6.983-3 10.713c0 3.73 3.02 6.75 6.75 6.75s6.75-3.02 6.75-6.75z"/>
</svg>
RSS Feed
</a>
<a
href={`https://podcasts.apple.com/?feedUrl=${encodeURIComponent(rssUrl)}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors"
>
🍎 Apple Podcasts
</a>
<a
href={`https://open.spotify.com/?feedUrl=${encodeURIComponent(rssUrl)}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors"
>
🎵 Spotify
</a>
</div>
</div>
</div>
</div>
{/* Now Playing */}
{currentEpisode && (
<div className="bg-gray-50 rounded-xl p-4 mb-8 border border-gray-200 sticky top-20 z-40 dark:bg-slate-900 dark:border-slate-700">
<div className="flex items-center gap-4">
@ -213,26 +195,19 @@ export default function PodcastPage() {
onClick={togglePlay}
className="w-12 h-12 bg-blue-600 hover:bg-blue-700 rounded-full flex items-center justify-center text-white transition-colors"
>
{isPlaying ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
) : (
<svg className="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
)}
{isPlaying ? "❚❚" : "▶"}
</button>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-slate-100 truncate">
{currentEpisode.title}
</h3>
<p className="text-sm text-gray-500 dark:text-slate-400">
{format(new Date(currentEpisode.date), "MMMM d, yyyy")}
{format(toDate(currentEpisode.date), "MMMM d, yyyy")}
</p>
</div>
<div className="hidden sm:block text-sm text-gray-500 dark:text-slate-400">
{formatDuration(Math.floor(progress))} / {formatDuration(currentEpisode.audioDuration)}
{formatDuration(Math.floor(progress))} /{" "}
{formatDuration(currentEpisode.audioDuration)}
</div>
</div>
<input
@ -252,20 +227,16 @@ export default function PodcastPage() {
</div>
)}
{/* Episode List */}
<div className="space-y-4">
<h2 className="text-xl font-semibold mb-4">Episodes</h2>
{loading ? (
<div className="text-center py-12">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto"/>
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto" />
</div>
) : episodes.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-xl dark:bg-slate-900">
<p className="text-gray-500 dark:text-slate-400">No podcast episodes yet.</p>
<p className="text-sm text-gray-400 dark:text-slate-500 mt-2">
Episodes are generated when new digests are posted with audio enabled.
</p>
</div>
) : (
episodes.map((episode) => (
@ -282,19 +253,14 @@ export default function PodcastPage() {
onClick={() => handlePlay(episode)}
className="w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-full flex items-center justify-center text-gray-700 transition-colors flex-shrink-0 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300"
>
{currentEpisode?.id === episode.id && isPlaying ? (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
) : (
<svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
)}
{currentEpisode?.id === episode.id && isPlaying ? "❚❚" : "▶"}
</button>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-1">
<Link href={`/?post=${episode.id}`} className="hover:text-blue-600 dark:hover:text-blue-400">
<Link
href={`/?post=${episode.id}`}
className="hover:text-blue-600 dark:hover:text-blue-400"
>
{episode.title}
</Link>
</h3>
@ -302,7 +268,7 @@ export default function PodcastPage() {
{episode.description}
</p>
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-slate-500">
<span>{format(new Date(episode.date), "MMMM d, yyyy")}</span>
<span>{format(toDate(episode.date), "MMMM d, yyyy")}</span>
<span>·</span>
<span>{formatDuration(episode.audioDuration)}</span>
{episode.tags && episode.tags.length > 0 && (
@ -321,12 +287,10 @@ export default function PodcastPage() {
)}
</div>
{/* Subscribe Section */}
<div className="mt-12 p-6 bg-gray-50 rounded-xl border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-2">Subscribe to the Podcast</h3>
<p className="text-sm text-gray-600 dark:text-slate-400 mb-4">
Get the latest tech digest delivered to your favorite podcast app.
</p>
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-2">
Subscribe to the Podcast
</h3>
<div className="flex flex-wrap gap-2">
<code className="px-3 py-1.5 bg-white border border-gray-300 rounded text-sm font-mono text-gray-700 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-300">
{rssUrl}
@ -340,15 +304,6 @@ export default function PodcastPage() {
</div>
</div>
</main>
{/* Footer */}
<footer className="border-t border-gray-200 mt-16 dark:border-slate-800">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<p className="text-center text-gray-500 dark:text-slate-400 text-sm">
© {new Date().getFullYear()} {DEFAULT_CONFIG.author}
</p>
</div>
</footer>
</div>
</>
);

129
src/lib/articles.ts Normal file
View File

@ -0,0 +1,129 @@
export interface ArticleRow {
id: string;
title: string;
summary: string;
source_url: string | null;
digest_date: string | null;
published_at: string;
created_at?: string;
tags: string[] | null;
audio_url: string | null;
audio_duration: number | null;
is_published?: boolean;
}
export interface Article {
id: string;
title: string;
summary: string;
sourceUrl: string | null;
digestDate: string | null;
publishedAt: string;
tags: string[];
audioUrl: string | null;
audioDuration: number | null;
}
export function mapArticleRow(row: ArticleRow): Article {
return {
id: row.id,
title: row.title,
summary: row.summary,
sourceUrl: row.source_url,
digestDate: row.digest_date,
publishedAt: row.published_at,
tags: row.tags || [],
audioUrl: row.audio_url,
audioDuration: row.audio_duration,
};
}
export function articleDateFromRow(row: Pick<ArticleRow, "digest_date" | "published_at">): string {
if (row.digest_date && /^\d{4}-\d{2}-\d{2}$/.test(row.digest_date)) {
return row.digest_date;
}
return row.published_at.slice(0, 10);
}
export function articleTimestampFromRow(
row: Pick<ArticleRow, "published_at">,
fallback = Date.now()
): number {
const parsed = new Date(row.published_at).getTime();
return Number.isFinite(parsed) ? parsed : fallback;
}
export function articleLegacyContent(input: {
title: string;
summary: string;
sourceUrl: string | null;
}): string {
const blocks: string[] = [`# ${input.title}`, "", input.summary];
if (input.sourceUrl) {
blocks.push("", `Source: ${input.sourceUrl}`);
}
return blocks.join("\n");
}
export function normalizeTags(input: unknown): string[] {
if (!Array.isArray(input)) {
return [];
}
const seen = new Set<string>();
for (const item of input) {
if (typeof item !== "string") {
continue;
}
const clean = item.trim();
if (clean) {
seen.add(clean);
}
}
return [...seen];
}
export function toDigestDate(input: unknown): string | null {
if (typeof input !== "string") {
return null;
}
const clean = input.trim();
if (!clean) {
return null;
}
return /^\d{4}-\d{2}-\d{2}$/.test(clean) ? clean : null;
}
export function toPublishedAt(input: unknown): string {
if (typeof input === "string" && input.trim()) {
const parsed = new Date(input);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
return new Date().toISOString();
}
export function articleNarrationText(article: Pick<Article, "title" | "summary">): string {
return `${article.title}. ${article.summary}`.trim();
}
interface SupabaseLikeError {
code?: string;
message?: string;
hint?: string | null;
}
export function isMissingBlogArticlesTableError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const shape = error as SupabaseLikeError;
if (shape.code === "PGRST205") {
const msg = `${shape.message || ""} ${shape.hint || ""}`.toLowerCase();
return msg.includes("blog_articles");
}
const message = (shape.message || "").toLowerCase();
return message.includes("blog_articles") && message.includes("not find");
}

43
src/lib/auth.ts Normal file
View File

@ -0,0 +1,43 @@
import { createClient } from "@supabase/supabase-js";
const authSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
export function getBearerToken(request: Request): string | null {
const authorization = request.headers.get("authorization");
if (!authorization?.startsWith("Bearer ")) {
return null;
}
const token = authorization.slice("Bearer ".length).trim();
return token.length > 0 ? token : null;
}
export async function getUserFromRequest(request: Request) {
const token = getBearerToken(request);
if (!token) {
return null;
}
const { data, error } = await authSupabase.auth.getUser(token);
if (error) {
return null;
}
return data.user;
}
export async function requireWriteAccess(request: Request): Promise<boolean> {
const apiKey = request.headers.get("x-api-key");
const cronApiKey = process.env.CRON_API_KEY;
if (cronApiKey && apiKey === cronApiKey) {
return true;
}
const user = await getUserFromRequest(request);
return !!user;
}

View File

@ -1,20 +1,18 @@
/**
* Podcast RSS Feed Generation Utilities
* Generates RSS 2.0 with iTunes extensions for podcast distribution
* Podcast RSS feed generation for article-level digests.
*/
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabase = createClient(supabaseUrl, supabaseAnonKey);
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
export interface PodcastEpisode {
id: string;
title: string;
description: string;
content: string;
date: string;
timestamp: number;
audioUrl?: string;
@ -34,53 +32,28 @@ export interface PodcastConfig {
explicit: boolean;
}
// Default podcast configuration
function defaultSiteUrl(): string {
return process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") || "https://blog.twisteddevices.com";
}
export const DEFAULT_CONFIG: PodcastConfig = {
title: "OpenClaw Daily Digest",
description: "Daily curated tech news covering AI coding assistants, iOS development, and digital entrepreneurship. AI-powered summaries delivered as audio.",
description:
"Daily curated tech news covering AI coding assistants, iOS development, and digital entrepreneurship.",
author: "OpenClaw",
email: "podcast@openclaw.ai",
category: "Technology",
language: "en-US",
websiteUrl: "https://blog-backup-two.vercel.app",
imageUrl: "https://blog-backup-two.vercel.app/podcast-cover.png",
websiteUrl: defaultSiteUrl(),
imageUrl: `${defaultSiteUrl()}/podcast-cover.png`,
explicit: false,
};
/**
* Parse title from markdown content
*/
export function extractTitle(content: string): string {
const lines = content.split("\n");
const titleLine = lines.find((l) => l.startsWith("# ") || l.startsWith("## "));
return titleLine?.replace(/#{1,2}\s/, "").trim() || "Daily Digest";
}
/**
* Extract plain text excerpt from markdown
*/
export function extractExcerpt(content: string, maxLength: number = 300): string {
const plainText = content
.replace(/#{1,6}\s/g, "")
.replace(/(\*\*|__|\*|_)/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/```[\s\S]*?```/g, "")
.replace(/`([^`]+)`/g, "$1")
.replace(/<[^>]+>/g, "")
.replace(/\n+/g, " ")
.trim();
if (plainText.length <= maxLength) return plainText;
return plainText.substring(0, maxLength).trim() + "...";
}
/**
* Format duration in seconds to HH:MM:SS or MM:SS
*/
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const safe = Math.max(0, Math.floor(seconds || 0));
const hours = Math.floor(safe / 3600);
const minutes = Math.floor((safe % 3600) / 60);
const secs = safe % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
@ -88,17 +61,10 @@ export function formatDuration(seconds: number): string {
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}
/**
* Format date to RFC 2822 format for RSS
*/
export function formatRFC2822(date: Date | string | number): string {
const d = new Date(date);
return d.toUTCString();
return new Date(date).toUTCString();
}
/**
* Escape XML special characters
*/
export function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;")
@ -108,22 +74,17 @@ export function escapeXml(text: string): string {
.replace(/'/g, "&apos;");
}
/**
* Generate unique GUID for episode
*/
export function generateGuid(episodeId: string): string {
return `openclaw-digest-${episodeId}`;
export function generateGuid(articleId: string): string {
return `openclaw-article-audio-${articleId}`;
}
/**
* Fetch episodes from database
*/
export async function fetchEpisodes(limit: number = 50): Promise<PodcastEpisode[]> {
export async function fetchEpisodes(limit = 50): Promise<PodcastEpisode[]> {
const { data, error } = await supabase
.from("blog_messages")
.select("id, date, content, timestamp, audio_url, audio_duration, tags")
.not("audio_url", "is", null) // Only episodes with audio
.order("timestamp", { ascending: false })
.from("blog_articles")
.select("id, title, summary, digest_date, published_at, audio_url, audio_duration, tags")
.eq("is_published", true)
.not("audio_url", "is", null)
.order("published_at", { ascending: false })
.limit(limit);
if (error) {
@ -131,45 +92,49 @@ export async function fetchEpisodes(limit: number = 50): Promise<PodcastEpisode[
throw error;
}
return (data || []).map(item => ({
id: item.id,
title: extractTitle(item.content),
description: extractExcerpt(item.content),
content: item.content,
date: item.date,
timestamp: item.timestamp,
audioUrl: item.audio_url,
audioDuration: item.audio_duration || 300, // Default 5 min if not set
tags: item.tags || [],
}));
return (data || []).map((item) => {
const timestamp = Number.isFinite(new Date(item.published_at).getTime())
? new Date(item.published_at).getTime()
: Date.now();
return {
id: item.id,
title: item.title,
description: item.summary,
date: item.digest_date || item.published_at.slice(0, 10),
timestamp,
audioUrl: item.audio_url || undefined,
audioDuration: item.audio_duration || 300,
tags: item.tags || [],
};
});
}
/**
* Generate RSS feed XML
*/
export function generateRSS(episodes: PodcastEpisode[], config: PodcastConfig = DEFAULT_CONFIG): string {
export function generateRSS(
episodes: PodcastEpisode[],
config: PodcastConfig = DEFAULT_CONFIG
): string {
const now = new Date();
const lastBuildDate = episodes.length > 0
? new Date(episodes[0].timestamp)
: now;
const lastBuildDate = episodes.length > 0 ? new Date(episodes[0].timestamp) : now;
const itemsXml = episodes.map(episode => {
const guid = generateGuid(episode.id);
const pubDate = formatRFC2822(episode.timestamp);
const duration = formatDuration(episode.audioDuration || 300);
const enclosureUrl = escapeXml(episode.audioUrl || "");
const title = escapeXml(episode.title);
const description = escapeXml(episode.description);
const keywords = episode.tags?.join(", ") || "technology, ai, programming";
const itemsXml = episodes
.map((episode) => {
const guid = generateGuid(episode.id);
const pubDate = formatRFC2822(episode.timestamp);
const duration = formatDuration(episode.audioDuration || 300);
const enclosureUrl = escapeXml(episode.audioUrl || "");
const title = escapeXml(episode.title);
const description = escapeXml(episode.description);
const keywords = episode.tags?.join(", ") || "technology, ai, programming";
return `
return `
<item>
<title>${title}</title>
<description>${description}</description>
<pubDate>${pubDate}</pubDate>
<guid isPermaLink="false">${guid}</guid>
<link>${escapeXml(`${config.websiteUrl}/?post=${episode.id}`)}</link>
<enclosure url="${enclosureUrl}" length="${episode.audioDuration || 300}" type="audio/mpeg"/>
<enclosure url="${enclosureUrl}" length="0" type="audio/mpeg"/>
<itunes:title>${title}</itunes:title>
<itunes:author>${escapeXml(config.author)}</itunes:author>
<itunes:summary>${description}</itunes:summary>
@ -177,7 +142,8 @@ export function generateRSS(episodes: PodcastEpisode[], config: PodcastConfig =
<itunes:explicit>${config.explicit ? "yes" : "no"}</itunes:explicit>
<itunes:keywords>${escapeXml(keywords)}</itunes:keywords>
</item>`;
}).join("\n");
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">

View File

@ -3,6 +3,8 @@
*/
import { createClient } from "@supabase/supabase-js";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { join, dirname } from "path";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
@ -21,6 +23,25 @@ export interface UploadResult {
/**
* Ensure the podcast-audio bucket exists
*/
// Local file storage (for Synology部署)
async function saveLocal(audioBuffer: Buffer, filename: string): Promise<UploadResult> {
const localPath = process.env.PODCAST_AUDIO_PATH || "./public/audio";
if (!existsSync(localPath)) {
mkdirSync(localPath, { recursive: true });
}
const filePath = join(localPath, filename);
writeFileSync(filePath, audioBuffer);
return {
url: filePath, // Return local path
path: filename,
size: audioBuffer.length
};
}
export async function ensureBucket(): Promise<void> {
try {
// Check if bucket exists
@ -62,6 +83,13 @@ export async function uploadAudio(
filename: string,
contentType: string = "audio/mpeg"
): Promise<UploadResult> {
// Check for local storage first
const localPath = process.env.PODCAST_AUDIO_PATH;
if (localPath) {
console.log("[Storage] Saving to local path:", localPath);
return saveLocal(buffer, filename);
}
await ensureBucket();
const { data, error } = await supabase.storage

View File

@ -2,66 +2,52 @@
* Text-to-Speech Service
* Supports multiple TTS providers: Piper (local/free), OpenAI (API/paid)
*/
export interface TTSOptions {
provider?: "piper" | "openai" | "macsay";
provider?: "piper" | "openai" | "macsay" | "kokoro";
voice?: string;
model?: string;
speed?: number;
}
export interface TTSResult {
audioBuffer: Buffer;
duration: number; // estimated duration in seconds
format: string;
}
// Abstract TTS Provider Interface
interface TTSProvider {
synthesize(text: string, options?: TTSOptions): Promise<TTSResult>;
}
// Piper TTS Provider (Local, Free)
class PiperProvider implements TTSProvider {
private modelPath: string;
constructor() {
// Default model path - can be configured via env
this.modelPath = process.env.PIPER_MODEL_PATH || "./models/en_US-lessac-medium.onnx";
}
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
const { exec } = await import("child_process");
const { promisify } = await import("util");
const fs = await import("fs");
const path = await import("path");
const os = await import("os");
const execAsync = promisify(exec);
// Create temp directory for output
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "piper-"));
const outputPath = path.join(tempDir, "output.wav");
try {
// Check if piper is installed
await execAsync("which piper || which piper-tts");
// Run Piper TTS
const piperCmd = `echo ${JSON.stringify(text)} | piper --model "${this.modelPath}" --output_file "${outputPath}"`;
await execAsync(piperCmd, { timeout: 60000 });
// Read the output file
const audioBuffer = fs.readFileSync(outputPath);
// Estimate duration (rough: ~150 words per minute, ~5 chars per word)
const wordCount = text.split(/\s+/).length;
const estimatedDuration = Math.ceil((wordCount / 150) * 60);
// Cleanup
fs.unlinkSync(outputPath);
fs.rmdirSync(tempDir);
return {
audioBuffer,
duration: estimatedDuration,
@ -77,23 +63,19 @@ class PiperProvider implements TTSProvider {
}
}
}
// OpenAI TTS Provider (API-based, paid)
class OpenAIProvider implements TTSProvider {
private apiKey: string;
constructor() {
this.apiKey = process.env.OPENAI_API_KEY || "";
if (!this.apiKey) {
throw new Error("OPENAI_API_KEY not configured");
}
}
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
const voice = options?.voice || "alloy";
const model = options?.model || "tts-1";
const speed = options?.speed || 1.0;
const response = await fetch("https://api.openai.com/v1/audio/speech", {
method: "POST",
headers: {
@ -108,18 +90,14 @@ class OpenAIProvider implements TTSProvider {
response_format: "mp3",
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI TTS API error: ${response.status} ${error}`);
}
const audioBuffer = Buffer.from(await response.arrayBuffer());
// Estimate duration (rough calculation)
const wordCount = text.split(/\s+/).length;
const estimatedDuration = Math.ceil((wordCount / 150) * 60);
return {
audioBuffer,
duration: estimatedDuration,
@ -127,8 +105,62 @@ class OpenAIProvider implements TTSProvider {
};
}
}
// macOS say command (built-in, basic quality)
// Kokoro TTS Provider (Local, High Quality Neural)
class KokoroProvider implements TTSProvider {
private pythonBin: string;
private scriptPath: string;
constructor() {
this.pythonBin = process.env.KOKORO_PYTHON ||
"/Users/mattbruce/Documents/Projects/OpenClaw/Web/blog-creator/.venv-tts/bin/python";
this.scriptPath = process.env.KOKORO_SCRIPT ||
"/Users/mattbruce/Documents/Projects/OpenClaw/Web/blog-creator/scripts/tts_kokoro.py";
}
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
const { execFile } = await import("child_process");
const { promisify } = await import("util");
const fs = await import("fs");
const path = await import("path");
const os = await import("os");
const execAsync = promisify(execFile);
const voice = options?.voice || "af_bella";
const speed = options?.speed || 1.0;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "kokoro-"));
const txtPath = path.join(tempDir, "input.txt");
const wavPath = path.join(tempDir, "output.wav");
try {
fs.writeFileSync(txtPath, text, "utf8");
await execAsync(this.pythonBin, [
this.scriptPath,
"--text-file", txtPath,
"--out", wavPath,
"--voice", voice,
"--speed", String(speed)
], { timeout: 120000 });
const audioBuffer = fs.readFileSync(wavPath);
// Get actual duration from the generated file
const { execFile } = await import("child_process");
const { promisify } = await import("util");
const execAsync2 = promisify(execFile);
try {
const { stdout } = await execAsync2("ffprobe", [
"-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", wavPath
]);
var actualDuration = parseFloat(stdout.trim()) || 10;
} catch {
var actualDuration = 10;
}
return {
audioBuffer,
duration: Math.round(actualDuration),
format: "audio/wav"
};
} finally {
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
}
}
}
class MacSayProvider implements TTSProvider {
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
const { exec } = await import("child_process");
@ -136,28 +168,21 @@ class MacSayProvider implements TTSProvider {
const fs = await import("fs");
const path = await import("path");
const os = await import("os");
const execAsync = promisify(exec);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "say-"));
const outputPath = path.join(tempDir, "output.aiff");
const voice = options?.voice || "Samantha";
try {
// Use macOS say command
const sayCmd = `say -v "${voice}" -o "${outputPath}" ${JSON.stringify(text)}`;
await execAsync(sayCmd, { timeout: 120000 });
const audioBuffer = fs.readFileSync(outputPath);
// Estimate duration
const wordCount = text.split(/\s+/).length;
const estimatedDuration = Math.ceil((wordCount / 150) * 60);
// Cleanup
fs.unlinkSync(outputPath);
fs.rmdirSync(tempDir);
return {
audioBuffer,
duration: estimatedDuration,
@ -172,12 +197,10 @@ class MacSayProvider implements TTSProvider {
}
}
}
// Main TTS Service
export class TTSService {
private provider: TTSProvider;
constructor(provider: "piper" | "openai" | "macsay" = "openai") {
constructor(provider: "piper" | "openai" | "macsay" | "kokoro" = "openai") {
switch (provider) {
case "piper":
this.provider = new PiperProvider();
@ -185,13 +208,15 @@ export class TTSService {
case "macsay":
this.provider = new MacSayProvider();
break;
case "kokoro":
this.provider = new KokoroProvider();
break;
case "openai":
default:
this.provider = new OpenAIProvider();
break;
}
}
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
// Clean up text for TTS (remove markdown, URLs, etc.)
const cleanText = this.cleanTextForTTS(text);
@ -201,10 +226,8 @@ export class TTSService {
const truncatedText = cleanText.length > maxChars
? cleanText.substring(0, maxChars) + "... That's all for today."
: cleanText;
return this.provider.synthesize(truncatedText, options);
}
private cleanTextForTTS(text: string): string {
return text
// Remove markdown headers
@ -226,23 +249,123 @@ export class TTSService {
.trim();
}
}
// Convenience function
export async function generateSpeech(
text: string,
options?: TTSOptions
): Promise<TTSResult> {
const provider = (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai";
const provider =
(process.env.TTS_PROVIDER as "piper" | "openai" | "macsay" | "kokoro") ||
"openai";
const service = new TTSService(provider);
return service.synthesize(text, options);
}
// Lazy-loaded default service instance
let _tts: TTSService | null = null;
export function getTTS(): TTSService {
if (!_tts) {
const provider = (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai";
const provider =
(process.env.TTS_PROVIDER as "piper" | "openai" | "macsay" | "kokoro") ||
"openai";
_tts = new TTSService(provider);
}
return _tts;
}
// ============================================================================
// LAYERED PODCAST MIXING - Music UNDER Speech
// ============================================================================
async function getAudioDuration(filePath: string): Promise<number> {
const { exec: execSync } = await import("child_process");
const { promisify } = await import("util");
const execAsync = promisify(execSync);
try {
const { stdout } = await execAsync(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`);
return parseFloat(stdout.trim());
} catch {
return 10; // default fallback
}
}
/**
* Layered podcast mix: Music UNDER speech the entire time
* - Creates a music bed from intro track at 15% volume
* - Mixes speech with music bed using amix
* This is what real podcasts sound like
*/
// ============================================================================
// LAYERED PODCAST MIXING - Music UNDER Speech (WORKING VERSION)
// ============================================================================
export async function mixWithMusicLayered(
ttsResult: TTSResult,
options?: { introUrl?: string }
): Promise<TTSResult> {
const { exec: execSync } = await import("child_process");
const { promisify } = await import("util");
const fs = await import("fs");
const path = await import("path");
const os = await import("os");
const execAsync = promisify(execSync);
const introUrl = options?.introUrl || process.env.INTRO_MUSIC_URL || "";
if (!introUrl) {
console.log("[mixWithMusicLayered] No intro URL, returning raw TTS");
return ttsResult;
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "podcast-"));
const ttsPath = path.join(tempDir, "tts.wav");
const introPath = path.join(tempDir, "intro.mp3");
const outputPath = path.join(tempDir, "output.mp3");
try {
// Write TTS to temp (convert to wav for processing)
fs.writeFileSync(ttsPath, ttsResult.audioBuffer);
// Copy intro music
fs.copyFileSync(introUrl, introPath);
// Get durations
const { stdout: speechDur } = await execAsync(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${ttsPath}"`);
const speechDuration = parseFloat(speechDur.trim());
console.log(`[mixWithMusicLayered] Speech: ${speechDuration}s`);
// Professional layered mix using the recommended FFmpeg command
// Intro: 0-8s, Voice starts at 5s with 3s fade, Middle: looped 8-26, Outro: 26-34
const cmd = `ffmpeg -y -i "${ttsPath}" -i "${introPath}" -filter_complex "
[0:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,asetpts=PTS-STARTPTS[voice_raw];
[voice_raw]adelay=5000|5000,afade=t=in:st=5:d=3[voice];
[1:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,asetpts=PTS-STARTPTS[music_base];
[music_base]atrim=0:8,volume=0.3,afade=t=in:st=0:d=1.5[intro];
[music_base]atrim=8:26,asetpts=PTS-STARTPTS,volume=0.3[mid];
[mid]aloop=loop=-1:size=2147483647,atrim=0:${Math.ceil(speechDuration)}[mid_loop];
[intro][mid_loop]concat=n=2:v=0:a=1[music_timeline];
[music_timeline][voice]amix=inputs=2:duration=shortest:normalize=0[main];
[music_base]atrim=26:34,asetpts=PTS-STARTPTS,volume=0.3,afade=t=out:st=6:d=2[outro];
[main][outro]acrossfade=d=2:c1=tri:c2=tri[mix]
" -map "[mix]" -c:a libmp3lame -q:a 2 "${outputPath}"`;
console.log("[mixWithMusicLayered] Running professional mix...");
await execAsync(cmd, { timeout: 120000 });
if (!fs.existsSync(outputPath)) {
console.error("[mixWithMusicLayered] Output not created!");
return ttsResult;
}
const stats = fs.statSync(outputPath);
console.log("[mixWithMusicLayered] Output size:", stats.size);
const mixedBuffer = fs.readFileSync(outputPath);
return {
audioBuffer: mixedBuffer,
duration: Math.round(speechDuration + 8), // Add intro time
format: "audio/mpeg",
};
} catch (error) {
console.error("[mixWithMusicLayered] Error:", error);
return ttsResult;
} finally {
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
}
}

View File

@ -7,7 +7,7 @@
import { createClient } from "@supabase/supabase-js";
import { generateSpeech } from "@/lib/tts";
import { uploadAudio } from "@/lib/storage";
import { extractTitle } from "@/lib/podcast";
import { articleNarrationText } from "@/lib/articles";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
@ -15,22 +15,22 @@ const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
async function generateTTSForPost(postId: string) {
console.log(`Generating TTS for post ${postId}...`);
console.log(`Generating TTS for article ${postId}...`);
// Fetch post from database
const { data: post, error: fetchError } = await supabase
.from("blog_messages")
.select("id, content, date, audio_url")
// Fetch article from database
const { data: article, error: fetchError } = await supabase
.from("blog_articles")
.select("id, title, summary, digest_date, audio_url")
.eq("id", postId)
.single();
if (fetchError || !post) {
console.error("Error fetching post:", fetchError);
if (fetchError || !article) {
console.error("Error fetching article:", fetchError);
process.exit(1);
}
if (post.audio_url) {
console.log("Post already has audio:", post.audio_url);
if (article.audio_url) {
console.log("Article already has audio:", article.audio_url);
console.log("Use --force to regenerate");
if (!process.argv.includes("--force")) {
@ -41,11 +41,14 @@ async function generateTTSForPost(postId: string) {
try {
console.log("Generating speech...");
const title = extractTitle(post.content);
console.log(`Title: ${title}`);
console.log(`Title: ${article.title}`);
const narration = articleNarrationText({
title: article.title,
summary: article.summary,
});
const { audioBuffer, duration, format } = await generateSpeech(post.content, {
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai",
const { audioBuffer, duration, format } = await generateSpeech(narration, {
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay" | "kokoro") || "openai",
voice: process.env.TTS_VOICE || "alloy",
});
@ -55,15 +58,19 @@ async function generateTTSForPost(postId: string) {
const ext = format === "audio/wav" ? "wav" :
format === "audio/aiff" ? "aiff" : "mp3";
const filename = `digest-${post.date}-${post.id}.${ext}`;
const dateSlug = (article.digest_date || new Date().toISOString().slice(0, 10)).replace(
/[^a-zA-Z0-9-]/g,
""
);
const filename = `article-${dateSlug}-${article.id}.${ext}`;
console.log(`Uploading to storage as ${filename}...`);
const { url, path } = await uploadAudio(audioBuffer, filename, format);
const { url } = await uploadAudio(audioBuffer, filename, format);
console.log(`Uploaded to: ${url}`);
// Update database
const { error: updateError } = await supabase
.from("blog_messages")
.from("blog_articles")
.update({
audio_url: url,
audio_duration: duration,
@ -86,49 +93,57 @@ async function generateTTSForPost(postId: string) {
}
async function generateTTSForAllMissing() {
console.log("Generating TTS for all posts without audio...");
console.log("Generating TTS for all articles without audio...");
const { data: posts, error } = await supabase
.from("blog_messages")
.select("id, content, date, audio_url")
const { data: articles, error } = await supabase
.from("blog_articles")
.select("id, title, summary, digest_date, published_at, audio_url")
.eq("is_published", true)
.is("audio_url", null)
.order("timestamp", { ascending: false });
.order("published_at", { ascending: false });
if (error) {
console.error("Error fetching posts:", error);
console.error("Error fetching articles:", error);
process.exit(1);
}
console.log(`Found ${posts?.length || 0} posts without audio`);
console.log(`Found ${articles?.length || 0} articles without audio`);
for (const post of posts || []) {
console.log(`\n--- Processing post ${post.id} ---`);
for (const article of articles || []) {
console.log(`\n--- Processing article ${article.id} ---`);
try {
const title = extractTitle(post.content);
console.log(`Title: ${title}`);
console.log(`Title: ${article.title}`);
const narration = articleNarrationText({
title: article.title,
summary: article.summary,
});
const { audioBuffer, duration, format } = await generateSpeech(post.content, {
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai",
const { audioBuffer, duration, format } = await generateSpeech(narration, {
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay" | "kokoro") || "openai",
voice: process.env.TTS_VOICE || "alloy",
});
const ext = format === "audio/wav" ? "wav" :
format === "audio/aiff" ? "aiff" : "mp3";
const filename = `digest-${post.date}-${post.id}.${ext}`;
const dateSlug = (article.digest_date || article.published_at.slice(0, 10)).replace(
/[^a-zA-Z0-9-]/g,
""
);
const filename = `article-${dateSlug}-${article.id}.${ext}`;
const { url } = await uploadAudio(audioBuffer, filename, format);
await supabase
.from("blog_messages")
.from("blog_articles")
.update({
audio_url: url,
audio_duration: duration,
})
.eq("id", post.id);
.eq("id", article.id);
console.log(`✅ Generated: ${url} (${duration}s)`);
} catch (error) {
console.error(`❌ Failed for post ${post.id}:`, error);
console.error(`❌ Failed for article ${article.id}:`, error);
}
}
@ -146,8 +161,8 @@ async function main() {
await generateTTSForPost(postId);
} else {
console.log("Usage:");
console.log(" npx ts-node src/scripts/generate-tts.ts <post_id> # Generate for specific post");
console.log(" npx ts-node src/scripts/generate-tts.ts --all # Generate for all missing posts");
console.log(" npx ts-node src/scripts/generate-tts.ts <article_id> # Generate for specific article");
console.log(" npx ts-node src/scripts/generate-tts.ts --all # Generate for all missing articles");
console.log(" npx ts-node src/scripts/generate-tts.ts <id> --force # Regenerate existing");
process.exit(1);
}

View File

@ -0,0 +1,41 @@
-- Create normalized article table for blog-backup.
-- One row = one article. digest_date groups rows by daily digest.
CREATE TABLE IF NOT EXISTS public.blog_articles (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
summary TEXT NOT NULL,
source_url TEXT,
digest_date DATE,
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tags TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
audio_url TEXT,
audio_duration INTEGER,
is_published BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_blog_articles_published_at
ON public.blog_articles (published_at DESC);
CREATE INDEX IF NOT EXISTS idx_blog_articles_digest_date
ON public.blog_articles (digest_date DESC);
CREATE INDEX IF NOT EXISTS idx_blog_articles_tags
ON public.blog_articles USING GIN (tags);
CREATE INDEX IF NOT EXISTS idx_blog_articles_audio
ON public.blog_articles (audio_url)
WHERE audio_url IS NOT NULL;
COMMENT ON TABLE public.blog_articles IS
'One row per article. digest_date groups rows into a daily digest.';
COMMENT ON COLUMN public.blog_articles.summary IS
'Structured article summary body shown in UI and RSS.';
COMMENT ON COLUMN public.blog_articles.digest_date IS
'Logical daily digest date (YYYY-MM-DD). Multiple articles can share one date.';
COMMENT ON COLUMN public.blog_articles.is_published IS
'Controls public visibility in /api/articles and RSS.';