Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
6ed9c6d5b2
commit
f8aa0b57b2
107
README.md
107
README.md
@ -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`
|
||||
|
||||
1
memory/heartbeat-state.json
Normal file
1
memory/heartbeat-state.json
Normal file
@ -0,0 +1 @@
|
||||
{"lastChecks": {"email": 1772500752, "calendar": 1772503603, "weather": null, "missionControl": 1772503603, "git": 1772503603}}
|
||||
60
schema.sql
60
schema.sql
@ -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;
|
||||
|
||||
682
scripts/blog.sh
682
scripts/blog.sh
@ -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 "$@"
|
||||
|
||||
@ -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>
|
||||
|
||||
179
src/app/api/articles/[id]/route.ts
Normal file
179
src/app/api/articles/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
146
src/app/api/articles/route.ts
Normal file
146
src/app/api/articles/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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>`,
|
||||
{
|
||||
|
||||
@ -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" },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
485
src/app/page.tsx
485
src/app/page.tsx
@ -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">
|
||||
|
||||
@ -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
129
src/lib/articles.ts
Normal 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
43
src/lib/auth.ts
Normal 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;
|
||||
}
|
||||
|
||||
@ -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, "&")
|
||||
@ -108,22 +74,17 @@ export function escapeXml(text: string): string {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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">
|
||||
|
||||
@ -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
|
||||
|
||||
205
src/lib/tts.ts
205
src/lib/tts.ts
@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
41
supabase/migrations/20260303_create_blog_articles.sql
Normal file
41
supabase/migrations/20260303_create_blog_articles.sql
Normal 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.';
|
||||
Loading…
Reference in New Issue
Block a user