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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
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_URL`
|
||||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||||
- `SUPABASE_SERVICE_ROLE_KEY` (used by external scripts/tools if needed)
|
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||||
- `CRON_API_KEY`
|
- `CRON_API_KEY`
|
||||||
|
|
||||||
## Public vs admin access
|
## Database setup
|
||||||
|
|
||||||
- Public blog (`/`) is open to everyone.
|
Run migrations in `supabase/migrations` (or apply `schema.sql` for a fresh setup).
|
||||||
- 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).
|
|
||||||
|
|
||||||
## Login flow
|
Main table migration:
|
||||||
|
|
||||||
1. Open `/login`
|
- `supabase/migrations/20260303_create_blog_articles.sql`
|
||||||
2. Sign in with a Supabase Auth email/password user
|
|
||||||
3. You are redirected to `/admin`
|
|
||||||
|
|
||||||
## Digest automation endpoint
|
## API surface
|
||||||
|
|
||||||
- `POST /api/digest` requires `x-api-key: <CRON_API_KEY>`
|
### Primary article API
|
||||||
- Used for cron-based digest publishing
|
|
||||||
|
|
||||||
## 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
|
Auth for write routes:
|
||||||
- `GET /api/podcast/rss` - Podcast RSS feed (audio episodes)
|
|
||||||
|
|
||||||
## 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
|
- `POST /api/digest` (requires `x-api-key`)
|
||||||
- Upload audio files up to 50MB
|
- Accepts `date` + `articles[]`, inserts one `blog_articles` row per article
|
||||||
- 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)
|
|
||||||
|
|
||||||
### Usage
|
### RSS
|
||||||
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
|
|
||||||
|
|
||||||
### API Endpoints
|
- `GET /api/rss` - article feed (one item per article)
|
||||||
- `GET /api/audio?postId={id}` - Get audio info for a post
|
- `GET /api/podcast/rss` - podcast feed for rows with `audio_url`
|
||||||
- `POST /api/audio` - Upload audio file (requires auth)
|
|
||||||
- `DELETE /api/audio` - Remove audio from post (requires auth)
|
|
||||||
|
|
||||||
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}}
|
||||||
58
schema.sql
58
schema.sql
@ -1,24 +1,46 @@
|
|||||||
-- Blog Backup Database Schema
|
-- Blog Backup database schema (article-first).
|
||||||
-- Run this SQL in Supabase Dashboard SQL Editor
|
-- Apply in Supabase SQL Editor for a fresh setup.
|
||||||
|
|
||||||
-- Add audio URL column for storing podcast audio file URLs
|
CREATE TABLE IF NOT EXISTS public.blog_articles (
|
||||||
ALTER TABLE blog_messages
|
id TEXT PRIMARY KEY,
|
||||||
ADD COLUMN IF NOT EXISTS audio_url TEXT;
|
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)
|
CREATE INDEX IF NOT EXISTS idx_blog_articles_published_at
|
||||||
ALTER TABLE blog_messages
|
ON public.blog_articles (published_at DESC);
|
||||||
ADD COLUMN IF NOT EXISTS audio_duration INTEGER;
|
|
||||||
|
|
||||||
-- Create index for faster queries on posts with audio
|
CREATE INDEX IF NOT EXISTS idx_blog_articles_digest_date
|
||||||
CREATE INDEX IF NOT EXISTS idx_blog_messages_audio
|
ON public.blog_articles (digest_date DESC);
|
||||||
ON blog_messages(audio_url)
|
|
||||||
WHERE audio_url IS NOT NULL;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN blog_messages.audio_url IS 'Public URL to the audio file in Supabase Storage';
|
CREATE INDEX IF NOT EXISTS idx_blog_articles_tags
|
||||||
COMMENT ON COLUMN blog_messages.audio_duration IS 'Audio duration in seconds (estimated from file size)';
|
ON public.blog_articles USING GIN (tags);
|
||||||
|
|
||||||
-- Verify columns were added
|
CREATE INDEX IF NOT EXISTS idx_blog_articles_audio
|
||||||
SELECT column_name, data_type
|
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
|
FROM information_schema.columns
|
||||||
WHERE table_name = 'blog_messages'
|
WHERE table_name IN ('blog_articles')
|
||||||
ORDER BY ordinal_position;
|
ORDER BY table_name, ordinal_position;
|
||||||
|
|||||||
682
scripts/blog.sh
682
scripts/blog.sh
@ -1,296 +1,444 @@
|
|||||||
#!/bin/bash
|
#!/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
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
DEFAULT_BLOG_API_URL="https://blog.twisteddevices.com/api"
|
||||||
|
|
||||||
# Set default API URL
|
if [[ -f "$PROJECT_ROOT/.env.local" ]]; then
|
||||||
export BLOG_API_URL="${BLOG_API_URL:-https://blog-backup-two.vercel.app/api}"
|
source "$PROJECT_ROOT/.env.local"
|
||||||
|
fi
|
||||||
# Check for machine token
|
|
||||||
if [[ -z "${BLOG_MACHINE_TOKEN:-}" ]]; then
|
export BLOG_API_URL="${BLOG_API_URL:-$DEFAULT_BLOG_API_URL}"
|
||||||
# Try to load from .env.local
|
|
||||||
if [[ -f "$PROJECT_ROOT/.env.local" ]]; then
|
if [[ -z "${BLOG_MACHINE_TOKEN:-}" ]]; then
|
||||||
source "$PROJECT_ROOT/.env.local"
|
if [[ -n "${CRON_API_KEY:-}" ]]; then
|
||||||
fi
|
export BLOG_MACHINE_TOKEN="$CRON_API_KEY"
|
||||||
|
fi
|
||||||
# Check if CRON_API_KEY is available
|
|
||||||
if [[ -n "${CRON_API_KEY:-}" ]]; then
|
|
||||||
export BLOG_MACHINE_TOKEN="$CRON_API_KEY"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Source the skill library
|
|
||||||
source "${HOME}/.agents/skills/blog-backup/lib/blog.sh"
|
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() {
|
usage() {
|
||||||
cat << 'EOF'
|
cat <<'EOF'
|
||||||
Blog Backup CLI
|
Blog Backup CLI
|
||||||
|
|
||||||
Usage: ./blog.sh <command> [args]
|
Usage: ./blog.sh <command> [args]
|
||||||
|
|
||||||
Commands:
|
Environment:
|
||||||
post [args] Create a new digest post
|
BLOG_API_URL API base (default: https://blog.twisteddevices.com/api)
|
||||||
--date YYYY-MM-DD Date for the digest (required)
|
For local dev use: BLOG_API_URL=http://localhost:3002/api
|
||||||
--content "..." Content in markdown (required)
|
BLOG_MACHINE_TOKEN Optional machine token (falls back to CRON_API_KEY from .env.local)
|
||||||
--tags '["tag1","tag2"]' Tags as JSON array (default: ["daily-digest"])
|
|
||||||
|
|
||||||
list [args] List digests
|
Modern article commands:
|
||||||
--limit N Max results (default: 20)
|
article-add [args] Add one article (recommended)
|
||||||
--since YYYY-MM-DD Filter by date
|
--title "..." Article title (required)
|
||||||
--json Output as JSON
|
--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
|
search <query> Search digests by content
|
||||||
--limit N Max results (default: 20)
|
status <date> Check if digest exists for date
|
||||||
|
|
||||||
status <date> Check if digest exists for a date (YYYY-MM-DD)
|
|
||||||
|
|
||||||
health Check API health
|
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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
# Post command
|
handle_article_add() {
|
||||||
handle_post() {
|
local TITLE=""
|
||||||
local DATE=""
|
local SUMMARY=""
|
||||||
local CONTENT=""
|
local SOURCE_URL=""
|
||||||
local TAGS='["daily-digest"]'
|
local DIGEST_DATE="$(date +%F)"
|
||||||
|
local PUBLISHED_AT=""
|
||||||
|
local TAGS=""
|
||||||
|
local JSON_OUTPUT=false
|
||||||
|
|
||||||
# Parse arguments
|
while [[ $# -gt 0 ]]; do
|
||||||
while [[ $# -gt 0 ]]; do
|
case "$1" in
|
||||||
case "$1" in
|
--title) TITLE="${2:-}"; shift 2 ;;
|
||||||
--date)
|
--summary) SUMMARY="${2:-}"; shift 2 ;;
|
||||||
DATE="$2"
|
--url) SOURCE_URL="${2:-}"; shift 2 ;;
|
||||||
shift 2
|
--date) DIGEST_DATE="${2:-}"; shift 2 ;;
|
||||||
;;
|
--published-at) PUBLISHED_AT="${2:-}"; shift 2 ;;
|
||||||
--content)
|
--tags) TAGS="${2:-}"; shift 2 ;;
|
||||||
CONTENT="$2"
|
--json) JSON_OUTPUT=true; shift ;;
|
||||||
shift 2
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||||
;;
|
|
||||||
--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
|
|
||||||
;;
|
|
||||||
esac
|
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 "$@"
|
main "$@"
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { createClient, type User } from "@supabase/supabase-js";
|
import { createClient, type User } from "@supabase/supabase-js";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import { AudioUpload } from "@/components/AudioUpload";
|
import { AudioUpload } from "@/components/AudioUpload";
|
||||||
|
|
||||||
interface Message {
|
interface Article {
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
title: string;
|
||||||
content: string;
|
summary: string;
|
||||||
timestamp: number;
|
sourceUrl: string | null;
|
||||||
tags?: string[];
|
digestDate: string | null;
|
||||||
audio_url?: string | null;
|
publishedAt: string;
|
||||||
audio_duration?: number | null;
|
tags: string[];
|
||||||
|
audioUrl: string | null;
|
||||||
|
audioDuration: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type Theme = "light" | "dark";
|
||||||
@ -25,32 +25,38 @@ const supabase = createClient(
|
|||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
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() {
|
export default function AdminPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
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>(() => {
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return "light";
|
return "light";
|
||||||
}
|
}
|
||||||
|
const stored = localStorage.getItem("theme");
|
||||||
const storedTheme = localStorage.getItem("theme");
|
if (stored === "light" || stored === "dark") {
|
||||||
if (storedTheme === "light" || storedTheme === "dark") {
|
return stored;
|
||||||
return storedTheme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
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(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
localStorage.setItem("theme", theme);
|
localStorage.setItem("theme", theme);
|
||||||
@ -58,25 +64,36 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function runAuthCheck() {
|
async function runAuthCheck() {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const {
|
||||||
if (!user) {
|
data: { user: activeUser },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!activeUser) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUser(user);
|
setUser(activeUser);
|
||||||
}
|
}
|
||||||
|
void runAuthCheck();
|
||||||
runAuthCheck();
|
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
fetchMessages();
|
void fetchArticles();
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const sortedArticles = useMemo(
|
||||||
|
() =>
|
||||||
|
[...articles].sort(
|
||||||
|
(a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
|
||||||
|
),
|
||||||
|
[articles]
|
||||||
|
);
|
||||||
|
|
||||||
async function getAuthHeaders() {
|
async function getAuthHeaders() {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
if (!session?.access_token) {
|
if (!session?.access_token) {
|
||||||
throw new Error("No active session");
|
throw new Error("No active session");
|
||||||
}
|
}
|
||||||
@ -92,128 +109,125 @@ export default function AdminPage() {
|
|||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMessages() {
|
async function fetchArticles() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/messages");
|
const res = await fetch("/api/articles?limit=500");
|
||||||
const data = await res.json();
|
const payload = (await res.json()) as { articles?: Article[] };
|
||||||
if (Array.isArray(data)) {
|
setArticles(Array.isArray(payload.articles) ? payload.articles : []);
|
||||||
setMessages(data.sort((a, b) => b.timestamp - a.timestamp));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching messages:", error);
|
console.error("Error fetching articles:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
function resetForm() {
|
||||||
if (!confirm("Are you sure you want to delete this post?")) return;
|
setTitle("");
|
||||||
|
setSummary("");
|
||||||
try {
|
setSourceUrl("");
|
||||||
const headers = await getAuthHeaders();
|
setDigestDate("");
|
||||||
const res = await fetch("/api/messages", {
|
setTagsText("daily-digest");
|
||||||
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 handleEdit(post: Message) {
|
function loadFormFromArticle(article: Article) {
|
||||||
setEditingPost(post);
|
setTitle(article.title);
|
||||||
setEditContent(post.content);
|
setSummary(article.summary);
|
||||||
setEditDate(post.date);
|
setSourceUrl(article.sourceUrl || "");
|
||||||
setEditTags(post.tags?.join(", ") || "");
|
setDigestDate(article.digestDate || article.publishedAt.slice(0, 10));
|
||||||
}
|
setTagsText(article.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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
try {
|
try {
|
||||||
const headers = await getAuthHeaders();
|
const headers = await getAuthHeaders();
|
||||||
const res = await fetch("/api/messages", {
|
const res = await fetch("/api/articles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content: newContent,
|
title,
|
||||||
date: newDate,
|
summary,
|
||||||
tags: newTags.split(",").map((t) => t.trim()).filter(Boolean),
|
sourceUrl: sourceUrl || null,
|
||||||
|
digestDate: digestDate || null,
|
||||||
|
tags: toTagArray(tagsText),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
setNewContent("");
|
resetForm();
|
||||||
setNewDate("");
|
await fetchArticles();
|
||||||
setNewTags("daily-digest");
|
return;
|
||||||
fetchMessages();
|
|
||||||
} else if (res.status === 401) {
|
|
||||||
await handleUnauthorized();
|
|
||||||
} else {
|
|
||||||
alert("Failed to create post");
|
|
||||||
}
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
await handleUnauthorized();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert("Failed to create article");
|
||||||
} catch (error) {
|
} 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();
|
await handleUnauthorized();
|
||||||
alert("Error creating post");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,17 +262,22 @@ export default function AdminPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
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"
|
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"}
|
{theme === "dark" ? "Light mode" : "Dark mode"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
setShowCreateModal(true);
|
||||||
|
}}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
+ New Post
|
+ New Article
|
||||||
</button>
|
</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
|
View Site
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
@ -274,21 +293,19 @@ export default function AdminPage() {
|
|||||||
<main className="max-w-6xl mx-auto px-4 py-8">
|
<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="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">
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-slate-800">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">All Articles ({articles.length})</h2>
|
||||||
All Posts ({messages.length})
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-gray-200 dark:divide-slate-800">
|
<div className="divide-y divide-gray-200 dark:divide-slate-800">
|
||||||
{messages.map((post) => (
|
{sortedArticles.map((article) => (
|
||||||
<div key={post.id} className="p-6 hover:bg-gray-50 dark:hover:bg-slate-800/40">
|
<div key={article.id} className="p-6 hover:bg-gray-50 dark:hover:bg-slate-800/40">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<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">
|
<span className="text-sm text-gray-500 dark:text-slate-400">
|
||||||
{new Date(post.timestamp).toLocaleDateString()}
|
{new Date(article.publishedAt).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
{post.tags?.map((tag) => (
|
{(article.tags || []).map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
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"
|
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}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{post.audio_url && (
|
{article.audioUrl && (
|
||||||
<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">
|
<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">
|
||||||
<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>
|
|
||||||
Audio
|
Audio
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-slate-300 line-clamp-2">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-slate-100 mb-1">
|
||||||
{post.content.substring(0, 200)}...
|
{article.title}
|
||||||
</div>
|
</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>
|
||||||
<div className="flex gap-2 ml-4">
|
<div className="flex gap-2">
|
||||||
<button
|
<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"
|
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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@ -330,142 +360,113 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{(showCreateModal || editing) && (
|
||||||
{editingPost && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
<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="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">
|
<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>
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editDate}
|
value={title}
|
||||||
onChange={(e) => setEditDate(e.target.value)}
|
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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Tags (comma separated)</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">
|
||||||
<input
|
Summary
|
||||||
type="text"
|
</label>
|
||||||
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>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={editContent}
|
value={summary}
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
onChange={(e) => setSummary(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"
|
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>
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Preview</label>
|
<div>
|
||||||
<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">
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
Digest Date
|
||||||
{editContent}
|
</label>
|
||||||
</ReactMarkdown>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
{/* Audio Upload Section */}
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">
|
||||||
<div className="border-t border-gray-200 pt-4 dark:border-slate-800">
|
Source URL
|
||||||
<label className="block text-sm font-medium mb-3 text-gray-700 dark:text-slate-200">
|
|
||||||
🎧 Audio Attachment
|
|
||||||
</label>
|
</label>
|
||||||
<AudioUpload
|
<input
|
||||||
postId={editingPost.id}
|
type="url"
|
||||||
existingAudioUrl={editingPost.audio_url}
|
value={sourceUrl}
|
||||||
onUploadSuccess={(url, duration) => {
|
onChange={(e) => setSourceUrl(e.target.value)}
|
||||||
setEditingPost({
|
placeholder="https://..."
|
||||||
...editingPost,
|
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"
|
||||||
audio_url: url,
|
|
||||||
audio_duration: duration,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onDeleteSuccess={() => {
|
|
||||||
setEditingPost({
|
|
||||||
...editingPost,
|
|
||||||
audio_url: null,
|
|
||||||
audio_duration: null,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{editing && (
|
||||||
{showCreateModal && (
|
<div className="border-t border-gray-200 pt-4 dark:border-slate-800">
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
<label className="block text-sm font-medium mb-3 text-gray-700 dark:text-slate-200">
|
||||||
<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">
|
Audio Attachment
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-slate-800">
|
</label>
|
||||||
<h3 className="text-lg font-semibold">New Post</h3>
|
<AudioUpload
|
||||||
</div>
|
postId={editing.id}
|
||||||
<div className="p-6 space-y-4">
|
existingAudioUrl={editing.audioUrl}
|
||||||
<div>
|
onUploadSuccess={(url, duration) => {
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Date</label>
|
setEditing({
|
||||||
<input
|
...editing,
|
||||||
type="text"
|
audioUrl: url,
|
||||||
value={newDate}
|
audioDuration: duration,
|
||||||
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"
|
onDeleteSuccess={() => {
|
||||||
/>
|
setEditing({
|
||||||
</div>
|
...editing,
|
||||||
<div>
|
audioUrl: null,
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Tags (comma separated)</label>
|
audioDuration: null,
|
||||||
<input
|
});
|
||||||
type="text"
|
}}
|
||||||
value={newTags}
|
/>
|
||||||
onChange={(e) => setNewTags(e.target.value)}
|
</div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 border-t border-gray-200 dark:border-slate-800 flex justify-end gap-2">
|
<div className="p-6 border-t border-gray-200 dark:border-slate-800 flex justify-end gap-2">
|
||||||
<button
|
<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"
|
className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={() => void (editing ? handleSaveEdit() : handleCreate())}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Create Post
|
{editing ? "Save Changes" : "Create Article"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 });
|
return NextResponse.json({ error: "Post ID required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.select("id, audio_url, audio_duration")
|
.select("id, audio_url, audio_duration")
|
||||||
.eq("id", postId)
|
.eq("id", postId)
|
||||||
.single();
|
.single();
|
||||||
@ -107,9 +107,9 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get post info for filename
|
// Get post info for filename
|
||||||
const { data: post, error: postError } = await supabase
|
const { data: post, error: postError } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.select("date")
|
.select("digest_date, published_at")
|
||||||
.eq("id", postId)
|
.eq("id", postId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@ -123,7 +123,9 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// Create filename
|
// Create filename
|
||||||
const timestamp = Date.now();
|
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 extension = file.name.split(".").pop() || "mp3";
|
||||||
const filename = `upload-${sanitizedDate}-${postId}-${timestamp}.${extension}`;
|
const filename = `upload-${sanitizedDate}-${postId}-${timestamp}.${extension}`;
|
||||||
|
|
||||||
@ -165,7 +167,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// Update database with audio info
|
// Update database with audio info
|
||||||
const { error: updateError } = await serviceSupabase
|
const { error: updateError } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.update({
|
.update({
|
||||||
audio_url: urlData.publicUrl,
|
audio_url: urlData.publicUrl,
|
||||||
audio_duration: estimatedDuration,
|
audio_duration: estimatedDuration,
|
||||||
@ -215,8 +217,8 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current audio info
|
// Get current audio info
|
||||||
const { data: post, error: fetchError } = await supabase
|
const { data: post, error: fetchError } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.select("audio_url")
|
.select("audio_url")
|
||||||
.eq("id", postId)
|
.eq("id", postId)
|
||||||
.single();
|
.single();
|
||||||
@ -227,17 +229,21 @@ export async function DELETE(request: Request) {
|
|||||||
|
|
||||||
// Extract path from URL and delete from storage
|
// Extract path from URL and delete from storage
|
||||||
if (post?.audio_url) {
|
if (post?.audio_url) {
|
||||||
const url = new URL(post.audio_url);
|
try {
|
||||||
const pathMatch = url.pathname.match(/\/object\/public\/[^/]+\/(.+)/);
|
const url = new URL(post.audio_url);
|
||||||
if (pathMatch) {
|
const pathMatch = url.pathname.match(/\/object\/public\/[^/]+\/(.+)/);
|
||||||
const path = decodeURIComponent(pathMatch[1]);
|
if (pathMatch) {
|
||||||
await serviceSupabase.storage.from(BUCKET_NAME).remove([path]);
|
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
|
// Clear audio fields in database
|
||||||
const { error: updateError } = await serviceSupabase
|
const { error: updateError } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.update({
|
.update({
|
||||||
audio_url: null,
|
audio_url: null,
|
||||||
audio_duration: null,
|
audio_duration: null,
|
||||||
|
|||||||
@ -1,123 +1,117 @@
|
|||||||
/**
|
import crypto from "node:crypto";
|
||||||
* Daily Digest API Endpoint
|
|
||||||
* Saves digest content and optionally generates TTS audio
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
import { generateSpeech } from "@/lib/tts";
|
import {
|
||||||
import { uploadAudio } from "@/lib/storage";
|
isMissingBlogArticlesTableError,
|
||||||
import { extractTitle, extractExcerpt } from "@/lib/podcast";
|
normalizeTags,
|
||||||
|
toDigestDate,
|
||||||
|
} from "@/lib/articles";
|
||||||
|
|
||||||
const supabase = createClient(
|
export const runtime = "nodejs";
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
export const dynamic = "force-dynamic";
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
||||||
);
|
|
||||||
|
|
||||||
const serviceSupabase = createClient(
|
const serviceSupabase = createClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
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) {
|
export async function POST(request: Request) {
|
||||||
const apiKey = request.headers.get("x-api-key");
|
const apiKey = request.headers.get("x-api-key");
|
||||||
const CRON_API_KEY = process.env.CRON_API_KEY;
|
const cronApiKey = process.env.CRON_API_KEY;
|
||||||
|
if (!cronApiKey || apiKey !== cronApiKey) {
|
||||||
console.log("API Key from header:", apiKey);
|
|
||||||
console.log("CRON_API_KEY exists:", !!CRON_API_KEY);
|
|
||||||
|
|
||||||
if (!CRON_API_KEY || apiKey !== CRON_API_KEY) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
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 {
|
try {
|
||||||
console.log(`[TTS] Starting generation for digest ${id}...`);
|
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 : [];
|
||||||
|
|
||||||
// Generate speech
|
if (articles.length === 0) {
|
||||||
const { audioBuffer, duration, format } = await generateSpeech(content, {
|
return NextResponse.json(
|
||||||
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai",
|
{ error: "articles[] is required and must include at least one item" },
|
||||||
voice: process.env.TTS_VOICE || "alloy",
|
{ status: 400 }
|
||||||
});
|
);
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error(`[TTS] Failed to generate audio for digest ${id}:`, error);
|
console.error("POST /api/digest failed:", error);
|
||||||
// Don't throw - we don't want to fail the whole request
|
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,121 +1,216 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
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(
|
export const runtime = "nodejs";
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
export const dynamic = "force-dynamic";
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
||||||
);
|
|
||||||
|
|
||||||
const serviceSupabase = createClient(
|
const serviceSupabase = createClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
);
|
);
|
||||||
|
|
||||||
function getBearerToken(request: Request): string | null {
|
interface LegacyPayload {
|
||||||
const authorization = request.headers.get("authorization");
|
title?: string;
|
||||||
if (!authorization?.startsWith("Bearer ")) {
|
summary?: string;
|
||||||
return null;
|
content?: string;
|
||||||
}
|
sourceUrl?: string | null;
|
||||||
|
date?: string | null;
|
||||||
const token = authorization.slice("Bearer ".length).trim();
|
publishedAt?: string | null;
|
||||||
return token.length > 0 ? token : null;
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUserFromRequest(request: Request) {
|
function parseLegacyContent(raw: string): {
|
||||||
const token = getBearerToken(request);
|
title: string;
|
||||||
if (!token) {
|
summary: string;
|
||||||
return null;
|
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);
|
return {
|
||||||
if (error) {
|
title,
|
||||||
console.error("Error validating auth token:", error.message);
|
summary: summaryLines.join("\n").trim(),
|
||||||
return null;
|
sourceUrl,
|
||||||
}
|
};
|
||||||
|
|
||||||
return data.user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CRON_API_KEY = process.env.CRON_API_KEY;
|
// Legacy compatibility endpoint. Prefer /api/articles.
|
||||||
|
|
||||||
// GET is public (read-only)
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { data: messages, error } = await serviceSupabase
|
const { data, error } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.select("id, date, content, timestamp, tags")
|
.select(
|
||||||
.order("timestamp", { ascending: false });
|
"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) {
|
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({ 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) {
|
export async function POST(request: Request) {
|
||||||
const apiKey = request.headers.get("x-api-key");
|
const allowed = await requireWriteAccess(request);
|
||||||
|
if (!allowed) {
|
||||||
const hasCronAccess = !!CRON_API_KEY && apiKey === CRON_API_KEY;
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!hasCronAccess) {
|
|
||||||
const user = await getUserFromRequest(request);
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { content, date, tags } = await request.json();
|
const body = (await request.json().catch(() => ({}))) as LegacyPayload;
|
||||||
|
|
||||||
if (!content || !date) {
|
let title = body.title?.trim() || "";
|
||||||
return NextResponse.json({ error: "Content and date required" }, { status: 400 });
|
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 = {
|
if (!title || !summary) {
|
||||||
id: Date.now().toString(),
|
return NextResponse.json(
|
||||||
date,
|
{ error: "title and summary required (or content in legacy mode)" },
|
||||||
content,
|
{ status: 400 }
|
||||||
timestamp: Date.now(),
|
);
|
||||||
tags: tags || [],
|
}
|
||||||
|
|
||||||
|
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
|
const { data, error } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.insert(newMessage);
|
.insert(row)
|
||||||
|
.select(
|
||||||
|
"id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration"
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
|
||||||
if (error) {
|
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({ 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) {
|
export async function DELETE(request: Request) {
|
||||||
const apiKey = request.headers.get("x-api-key");
|
const allowed = await requireWriteAccess(request);
|
||||||
|
if (!allowed) {
|
||||||
const hasCronAccess = !!CRON_API_KEY && apiKey === CRON_API_KEY;
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!hasCronAccess) {
|
|
||||||
const user = await getUserFromRequest(request);
|
|
||||||
if (!user) {
|
|
||||||
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) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: "ID required" }, { status: 400 });
|
return NextResponse.json({ error: "ID required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await serviceSupabase
|
const { error } = await serviceSupabase.from("blog_articles").delete().eq("id", id);
|
||||||
.from("blog_messages")
|
|
||||||
.delete()
|
|
||||||
.eq("id", id);
|
|
||||||
|
|
||||||
if (error) {
|
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({ error: "Failed to delete message" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { fetchEpisodes, generateRSS, DEFAULT_CONFIG } from "@/lib/podcast";
|
import { fetchEpisodes, generateRSS, DEFAULT_CONFIG } from "@/lib/podcast";
|
||||||
|
import { isMissingBlogArticlesTableError } from "@/lib/articles";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"; // Always generate fresh RSS
|
export const dynamic = "force-dynamic"; // Always generate fresh RSS
|
||||||
|
|
||||||
@ -26,13 +27,16 @@ export async function GET() {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating RSS feed:", 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(
|
return new NextResponse(
|
||||||
`<?xml version="1.0" encoding="UTF-8"?>
|
`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>OpenClaw Daily Digest</title>
|
<title>OpenClaw Daily Digest</title>
|
||||||
<description>Error loading podcast feed. Please try again later.</description>
|
<description>${description}</description>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>`,
|
</rss>`,
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
interface DigestRow {
|
const serviceSupabase = createClient(
|
||||||
id: string;
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
date: string | null;
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
content: string;
|
);
|
||||||
timestamp: number | string;
|
|
||||||
tags: string[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeXml(input: string): string {
|
function escapeXml(input: string): string {
|
||||||
return input
|
return input
|
||||||
@ -25,101 +27,69 @@ function cdataSafe(input: string): string {
|
|||||||
return input.replaceAll("]]>", "]]]]><![CDATA[>");
|
return input.replaceAll("]]>", "]]]]><![CDATA[>");
|
||||||
}
|
}
|
||||||
|
|
||||||
function asRfc2822(value: string | number | null | undefined): string {
|
function articleContentEncoded(summary: string, sourceUrl: string | null): string {
|
||||||
if (typeof value === "number") {
|
const lines = [summary];
|
||||||
return new Date(value).toUTCString();
|
if (sourceUrl) {
|
||||||
|
lines.push(`Original source: ${sourceUrl}`);
|
||||||
}
|
}
|
||||||
|
return lines.join("\n\n");
|
||||||
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>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
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 {
|
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
|
const { data, error } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.select("id, date, content, timestamp, tags")
|
.select("id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration")
|
||||||
.order("timestamp", { ascending: false })
|
.eq("is_published", true)
|
||||||
.limit(100);
|
.order("published_at", { ascending: false })
|
||||||
|
.limit(150);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = (data || []) as DigestRow[];
|
const articles = ((data || []) as ArticleRow[]).map(mapArticleRow);
|
||||||
|
const itemsXml = articles
|
||||||
const itemsXml = rows
|
.map((article) => {
|
||||||
.map((row) => {
|
const link = article.sourceUrl || `${baseUrl}/?post=${article.id}`;
|
||||||
const title = escapeXml(extractTitle(row.content || "Daily Digest"));
|
const categories = article.tags
|
||||||
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 || [])
|
|
||||||
.map((tag) => `<category>${escapeXml(tag)}</category>`)
|
.map((tag) => `<category>${escapeXml(tag)}</category>`)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
return `<item>
|
return `<item>
|
||||||
<title>${title}</title>
|
<title>${escapeXml(article.title)}</title>
|
||||||
<link>${escapeXml(postUrl)}</link>
|
<link>${escapeXml(link)}</link>
|
||||||
<guid isPermaLink="false">${escapeXml(guid)}</guid>
|
<guid isPermaLink="false">${escapeXml(`openclaw-article-${article.id}`)}</guid>
|
||||||
<pubDate>${pubDate}</pubDate>
|
<pubDate>${new Date(article.publishedAt).toUTCString()}</pubDate>
|
||||||
<description>${excerpt}</description>
|
<description>${escapeXml(article.summary)}</description>
|
||||||
<content:encoded><![CDATA[${cdataSafe(row.content || "")}]]></content:encoded>
|
<content:encoded><![CDATA[${cdataSafe(
|
||||||
|
articleContentEncoded(article.summary, article.sourceUrl)
|
||||||
|
)}]]></content:encoded>
|
||||||
${categories}
|
${categories}
|
||||||
</item>`;
|
</item>`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
const lastBuildDate =
|
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"?>
|
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>
|
<channel>
|
||||||
<title>OpenClaw Daily Digest</title>
|
<title>OpenClaw Daily Digest</title>
|
||||||
<link>${escapeXml(baseUrl)}</link>
|
<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>
|
<language>en-us</language>
|
||||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
<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"/>
|
<atom:link href="${escapeXml(`${baseUrl}/api/rss`)}" rel="self" type="application/rss+xml"/>
|
||||||
${itemsXml}
|
${itemsXml}
|
||||||
</channel>
|
</channel>
|
||||||
@ -132,12 +102,18 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating digest RSS feed:", error);
|
console.error("GET /api/rss failed:", error);
|
||||||
return new NextResponse(buildErrorFeed(baseUrl), {
|
const description = isMissingBlogArticlesTableError(error)
|
||||||
status: 500,
|
? "Missing table public.blog_articles. Apply migration 20260303_create_blog_articles.sql."
|
||||||
headers: {
|
: "Error loading feed.";
|
||||||
"Content-Type": "application/xml; charset=utf-8",
|
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 { NextResponse } from "next/server";
|
||||||
import { generateSpeech, TTSOptions } from "@/lib/tts";
|
import { generateSpeech, mixWithMusicLayered } from "@/lib/tts";
|
||||||
import { uploadAudio } from "@/lib/storage";
|
import { uploadAudio } from "@/lib/storage";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
@ -28,18 +28,35 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { text, postId, options } = await request.json();
|
const body = await request.json();
|
||||||
|
const { text, postId, includeMusic, options } = body;
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return NextResponse.json({ error: "Text content is required" }, { status: 400 });
|
return NextResponse.json({ error: "Text content is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate TTS audio
|
// Generate TTS audio
|
||||||
const { audioBuffer, duration, format } = await generateSpeech(text, {
|
const ttsOptions = {
|
||||||
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai",
|
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay" | "kokoro") || "openai",
|
||||||
voice: process.env.TTS_VOICE || "alloy",
|
voice: process.env.TTS_VOICE || "alloy",
|
||||||
...options,
|
...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
|
// Determine file extension based on format
|
||||||
const ext = format === "audio/wav" ? "wav" :
|
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 provided, update the blog post with audio URL
|
||||||
if (postId) {
|
if (postId) {
|
||||||
const { error: updateError } = await serviceSupabase
|
const { error: updateError } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.update({
|
.update({
|
||||||
audio_url: url,
|
audio_url: url,
|
||||||
audio_duration: duration,
|
audio_duration: duration,
|
||||||
@ -100,7 +117,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await serviceSupabase
|
const { data, error } = await serviceSupabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.select("id, audio_url, audio_duration")
|
.select("id, audio_url, audio_duration")
|
||||||
.eq("id", postId)
|
.eq("id", postId)
|
||||||
.single();
|
.single();
|
||||||
|
|||||||
467
src/app/page.tsx
467
src/app/page.tsx
@ -1,32 +1,52 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, useState, useEffect, useMemo } from "react";
|
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
import { AudioPlayer } from "@/components/AudioPlayer";
|
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";
|
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() {
|
export default function BlogPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
@ -47,13 +67,13 @@ function BlogPageContent() {
|
|||||||
const selectedTag = searchParams?.get("tag") ?? null;
|
const selectedTag = searchParams?.get("tag") ?? null;
|
||||||
const selectedPostId = searchParams?.get("post") ?? null;
|
const selectedPostId = searchParams?.get("post") ?? null;
|
||||||
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [theme, setTheme] = useState<Theme>("light");
|
const [theme, setTheme] = useState<Theme>("light");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMessages();
|
void fetchArticles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -72,73 +92,60 @@ function BlogPageContent() {
|
|||||||
localStorage.setItem("theme", theme);
|
localStorage.setItem("theme", theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
async function fetchMessages() {
|
async function fetchArticles() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/messages");
|
const res = await fetch("/api/articles?limit=300");
|
||||||
const data = await res.json();
|
const payload = (await res.json()) as { articles?: Article[] };
|
||||||
setMessages(data);
|
setArticles(Array.isArray(payload.articles) ? payload.articles : []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch:", err);
|
console.error("Failed to fetch articles:", err);
|
||||||
|
setArticles([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all unique tags
|
|
||||||
const allTags = useMemo(() => {
|
const allTags = useMemo(() => {
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
messages.forEach((m) => m.tags?.forEach((t) => tags.add(t)));
|
for (const article of articles) {
|
||||||
return Array.from(tags).sort();
|
for (const tag of article.tags || []) {
|
||||||
}, [messages]);
|
tags.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...tags].sort();
|
||||||
|
}, [articles]);
|
||||||
|
|
||||||
// Filter messages
|
const filteredArticles = useMemo(() => {
|
||||||
const filteredMessages = useMemo(() => {
|
let list = [...articles];
|
||||||
let filtered = messages;
|
|
||||||
|
|
||||||
if (selectedTag) {
|
if (selectedTag) {
|
||||||
filtered = filtered.filter((m) => m.tags?.includes(selectedTag));
|
list = list.filter((item) => item.tags?.includes(selectedTag));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery.trim()) {
|
||||||
const query = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
filtered = filtered.filter(
|
list = list.filter(
|
||||||
(m) =>
|
(item) =>
|
||||||
m.content.toLowerCase().includes(query) ||
|
item.title.toLowerCase().includes(q) ||
|
||||||
m.tags?.some((t) => t.toLowerCase().includes(query))
|
item.summary.toLowerCase().includes(q) ||
|
||||||
|
item.tags.some((tag) => tag.toLowerCase().includes(q))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered.sort((a, b) => b.timestamp - a.timestamp);
|
return list.sort(
|
||||||
}, [messages, selectedTag, searchQuery]);
|
(a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
|
||||||
|
);
|
||||||
|
}, [articles, selectedTag, searchQuery]);
|
||||||
|
|
||||||
// Get featured post (most recent)
|
|
||||||
const featuredPost = filteredMessages[0];
|
|
||||||
const regularPosts = filteredMessages.slice(1);
|
|
||||||
const selectedPost = useMemo(
|
const selectedPost = useMemo(
|
||||||
() =>
|
() => (selectedPostId ? articles.find((item) => item.id === selectedPostId) || null : null),
|
||||||
selectedPostId
|
[articles, selectedPostId]
|
||||||
? messages.find((message) => message.id === selectedPostId) ?? null
|
|
||||||
: null,
|
|
||||||
[messages, selectedPostId]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse title from content
|
const featuredPost = !selectedTag && !searchQuery ? filteredArticles[0] : null;
|
||||||
function getTitle(content: string): string {
|
const regularPosts = featuredPost
|
||||||
const lines = content.split("\n");
|
? filteredArticles.filter((item) => item.id !== featuredPost.id)
|
||||||
const titleLine = lines.find((l) => l.startsWith("# ") || l.startsWith("## "));
|
: filteredArticles;
|
||||||
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() + "...";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -152,11 +159,13 @@ function BlogPageContent() {
|
|||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Daily Digest | OpenClaw Blog</title>
|
<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>
|
</Head>
|
||||||
|
|
||||||
<div className="min-h-screen bg-white text-gray-900 dark:bg-slate-950 dark:text-slate-100">
|
<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">
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<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 className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white font-bold">
|
||||||
📓
|
📓
|
||||||
</div>
|
</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>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-4 md:gap-6">
|
<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
|
🎧 Podcast
|
||||||
</Link>
|
</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">
|
<Link
|
||||||
Tasks
|
href="/admin"
|
||||||
</Link>
|
className="text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||||
<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">
|
|
||||||
Admin
|
Admin
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
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"
|
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"}
|
{theme === "dark" ? "Light mode" : "Dark mode"}
|
||||||
</button>
|
</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="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">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
{/* Main Content */}
|
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
{selectedPostId ? (
|
{selectedPostId ? (
|
||||||
selectedPost ? (
|
selectedPost ? (
|
||||||
@ -208,7 +217,7 @@ function BlogPageContent() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<header className="mb-8">
|
<header className="mb-8">
|
||||||
{selectedPost.tags && selectedPost.tags.length > 0 && (
|
{selectedPost.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
{selectedPost.tags.map((tag) => (
|
{selectedPost.tags.map((tag) => (
|
||||||
<button
|
<button
|
||||||
@ -221,19 +230,33 @@ function BlogPageContent() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-slate-100 mb-3">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-slate-100 mb-3">
|
||||||
{getTitle(selectedPost.content)}
|
{selectedPost.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
<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>
|
</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>
|
</header>
|
||||||
|
|
||||||
{/* Audio Player */}
|
{selectedPost.audioUrl && (
|
||||||
{selectedPost.audio_url && (
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<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>
|
<span className="text-sm font-medium text-gray-700 dark:text-slate-300">
|
||||||
|
🎧 Listen to this post
|
||||||
|
</span>
|
||||||
<Link
|
<Link
|
||||||
href="/podcast"
|
href="/podcast"
|
||||||
className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
@ -242,15 +265,15 @@ function BlogPageContent() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
url={selectedPost.audio_url}
|
url={selectedPost.audioUrl}
|
||||||
duration={selectedPost.audio_duration || undefined}
|
duration={selectedPost.audioDuration || undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="markdown-content">
|
<div className="markdown-content prose prose-gray max-w-none dark:prose-invert">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
{selectedPost.content}
|
{selectedPost.summary}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@ -267,138 +290,141 @@ function BlogPageContent() {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Page Title */}
|
<div className="mb-8">
|
||||||
<div className="mb-8">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-slate-100 mb-2">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-slate-100 mb-2">
|
{selectedTag ? `Posts tagged "${selectedTag}"` : "Latest Posts"}
|
||||||
{selectedTag ? `Posts tagged "${selectedTag}"` : "Latest Posts"}
|
</h1>
|
||||||
</h1>
|
<p className="text-gray-600 dark:text-slate-300">
|
||||||
<p className="text-gray-600 dark:text-slate-300">
|
{filteredArticles.length} post
|
||||||
{filteredMessages.length} post{filteredMessages.length !== 1 ? "s" : ""}
|
{filteredArticles.length !== 1 ? "s" : ""}
|
||||||
{selectedTag && (
|
{selectedTag && (
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="ml-4 text-blue-600 hover:text-blue-800 text-sm dark:text-blue-400 dark:hover:text-blue-300"
|
className="ml-4 text-blue-600 hover:text-blue-800 text-sm dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
>
|
>
|
||||||
Clear filter →
|
Clear filter →
|
||||||
</button>
|
</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>
|
|
||||||
)}
|
)}
|
||||||
|
</p>
|
||||||
|
</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">
|
{featuredPost && (
|
||||||
<Link href={`/?post=${post.id}`}>
|
<article className="mb-10">
|
||||||
{getTitle(post.content)}
|
<Link href={`/?post=${featuredPost.id}`} className="group block">
|
||||||
</Link>
|
<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">
|
||||||
</h3>
|
<div className="p-6 md:p-8">
|
||||||
|
{featuredPost.tags.length > 0 && (
|
||||||
<p className="text-gray-600 dark:text-slate-300 text-sm line-clamp-2 mb-3">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
{getExcerpt(post.content)}
|
{featuredPost.tags.slice(0, 3).map((tag) => (
|
||||||
</p>
|
<span
|
||||||
|
key={tag}
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-slate-400">
|
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"
|
||||||
<span>{format(parseDate(post.date), "MMMM d, yyyy")}</span>
|
>
|
||||||
<span>·</span>
|
{tag}
|
||||||
<span>3 min read</span>
|
</span>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
)}
|
||||||
))}
|
<span className="text-sm text-blue-600 font-medium mb-2 block dark:text-blue-400">
|
||||||
</div>
|
Featured Post
|
||||||
|
</span>
|
||||||
{filteredMessages.length === 0 && (
|
<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">
|
||||||
<div className="text-center py-16">
|
{featuredPost.title}
|
||||||
<p className="text-gray-500 dark:text-slate-400">No posts found.</p>
|
</h2>
|
||||||
{(selectedTag || searchQuery) && (
|
<p className="text-gray-600 dark:text-slate-300 mb-4 line-clamp-3">
|
||||||
<button
|
{plainExcerpt(featuredPost.summary, 220)}
|
||||||
onClick={() => {
|
</p>
|
||||||
router.push("/");
|
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-slate-400">
|
||||||
setSearchQuery("");
|
<span>{format(parseDate(displayDate(featuredPost)), "MMMM d, yyyy")}</span>
|
||||||
}}
|
<span>·</span>
|
||||||
className="mt-2 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
<span>{Math.max(1, Math.ceil(featuredPost.summary.length / 700))} min read</span>
|
||||||
>
|
</div>
|
||||||
Clear filters
|
</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="lg:col-span-4 space-y-6">
|
<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">
|
<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">
|
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">
|
||||||
Search
|
Search
|
||||||
@ -412,16 +438,13 @@ function BlogPageContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
|
<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>
|
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-3">Tags</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{allTags.map((tag) => (
|
{allTags.map((tag) => (
|
||||||
<button
|
<button
|
||||||
key={tag}
|
key={tag}
|
||||||
onClick={() =>
|
onClick={() => router.push(selectedTag === tag ? "/" : `/?tag=${tag}`)}
|
||||||
router.push(selectedTag === tag ? "/" : `/?tag=${tag}`)
|
|
||||||
}
|
|
||||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
selectedTag === tag
|
selectedTag === tag
|
||||||
? "bg-blue-600 text-white"
|
? "bg-blue-600 text-white"
|
||||||
@ -434,22 +457,19 @@ function BlogPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* About */}
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
|
<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>
|
<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">
|
<p className="text-sm text-gray-600 dark:text-slate-300">
|
||||||
Daily curated digests covering AI coding assistants, iOS development,
|
One article per row, grouped by digest date for daily publishing and RSS delivery.
|
||||||
OpenClaw updates, and digital entrepreneurship.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
|
<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>
|
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-3">Stats</h3>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600 dark:text-slate-300">Total posts</span>
|
<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>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600 dark:text-slate-300">Tags</span>
|
<span className="text-gray-600 dark:text-slate-300">Tags</span>
|
||||||
@ -461,7 +481,6 @@ function BlogPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="border-t border-gray-200 dark:border-slate-800 mt-16">
|
<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">
|
<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">
|
<p className="text-center text-gray-500 dark:text-slate-400 text-sm">
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { PodcastEpisode, DEFAULT_CONFIG } from "@/lib/podcast";
|
import { DEFAULT_CONFIG, type PodcastEpisode } from "@/lib/podcast";
|
||||||
|
|
||||||
interface EpisodeWithAudio extends PodcastEpisode {
|
interface EpisodeWithAudio extends PodcastEpisode {
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
@ -17,6 +17,13 @@ function formatDuration(seconds: number): string {
|
|||||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
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() {
|
export default function PodcastPage() {
|
||||||
const [episodes, setEpisodes] = useState<EpisodeWithAudio[]>([]);
|
const [episodes, setEpisodes] = useState<EpisodeWithAudio[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -27,36 +34,45 @@ export default function PodcastPage() {
|
|||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEpisodes();
|
void fetchEpisodes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentEpisode && audioRef.current) {
|
if (currentEpisode && audioRef.current) {
|
||||||
audioRef.current.play();
|
void audioRef.current.play();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}
|
}
|
||||||
}, [currentEpisode]);
|
}, [currentEpisode]);
|
||||||
|
|
||||||
async function fetchEpisodes() {
|
async function fetchEpisodes() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/messages");
|
const res = await fetch("/api/articles?withAudio=true&limit=200");
|
||||||
const data = await res.json();
|
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[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
// Filter to only episodes with audio
|
const episodesWithAudio = (payload.articles || [])
|
||||||
const episodesWithAudio = (data || [])
|
.filter((item) => !!item.audioUrl)
|
||||||
.filter((m: any) => m.audio_url)
|
.map((item) => ({
|
||||||
.map((m: any) => ({
|
id: item.id,
|
||||||
id: m.id,
|
title: item.title,
|
||||||
title: extractTitle(m.content),
|
description: item.summary,
|
||||||
description: extractExcerpt(m.content),
|
date: item.digestDate || item.publishedAt,
|
||||||
content: m.content,
|
timestamp: new Date(item.publishedAt).getTime(),
|
||||||
date: m.date,
|
audioUrl: item.audioUrl as string,
|
||||||
timestamp: m.timestamp,
|
audioDuration: item.audioDuration || 300,
|
||||||
audioUrl: m.audio_url,
|
tags: item.tags || [],
|
||||||
audioDuration: m.audio_duration || 300,
|
|
||||||
tags: m.tags || [],
|
|
||||||
}))
|
}))
|
||||||
.sort((a: any, b: any) => b.timestamp - a.timestamp);
|
.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
|
||||||
setEpisodes(episodesWithAudio);
|
setEpisodes(episodesWithAudio);
|
||||||
} catch (err) {
|
} 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) {
|
function handlePlay(episode: EpisodeWithAudio) {
|
||||||
if (currentEpisode?.id === episode.id) {
|
if (currentEpisode?.id === episode.id) {
|
||||||
togglePlay();
|
togglePlay();
|
||||||
@ -96,29 +92,32 @@ export default function PodcastPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
if (audioRef.current) {
|
if (!audioRef.current) {
|
||||||
if (isPlaying) {
|
return;
|
||||||
audioRef.current.pause();
|
|
||||||
} else {
|
|
||||||
audioRef.current.play();
|
|
||||||
}
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
}
|
}
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
void audioRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTimeUpdate() {
|
function handleTimeUpdate() {
|
||||||
if (audioRef.current) {
|
if (!audioRef.current) {
|
||||||
setProgress(audioRef.current.currentTime);
|
return;
|
||||||
setDuration(audioRef.current.duration || currentEpisode?.audioDuration || 0);
|
|
||||||
}
|
}
|
||||||
|
setProgress(audioRef.current.currentTime);
|
||||||
|
setDuration(audioRef.current.duration || currentEpisode?.audioDuration || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSeek(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleSeek(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const newTime = parseFloat(e.target.value);
|
const newTime = parseFloat(e.target.value);
|
||||||
if (audioRef.current) {
|
if (!audioRef.current) {
|
||||||
audioRef.current.currentTime = newTime;
|
return;
|
||||||
setProgress(newTime);
|
|
||||||
}
|
}
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
setProgress(newTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEnded() {
|
function handleEnded() {
|
||||||
@ -127,9 +126,8 @@ export default function PodcastPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const siteUrl =
|
const siteUrl =
|
||||||
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
|
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") || "https://blog.twisteddevices.com";
|
||||||
"https://blog-backup-two.vercel.app";
|
const rssUrl = `${siteUrl}/api/podcast/rss`;
|
||||||
const rssUrl = `${siteUrl}/api/rss`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -139,7 +137,6 @@ export default function PodcastPage() {
|
|||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<div className="min-h-screen bg-white text-gray-900 dark:bg-slate-950 dark:text-slate-100">
|
<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">
|
<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="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
@ -151,10 +148,16 @@ export default function PodcastPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-4">
|
<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
|
Blog
|
||||||
</Link>
|
</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
|
Admin
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
@ -163,8 +166,7 @@ export default function PodcastPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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-cyan-600 rounded-2xl p-8 text-white mb-8">
|
||||||
<div className="bg-gradient-to-br from-blue-600 to-purple-600 rounded-2xl p-8 text-white mb-8">
|
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
<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">
|
<div className="w-32 h-32 bg-white/20 rounded-xl flex items-center justify-center text-5xl">
|
||||||
🎙️
|
🎙️
|
||||||
@ -179,33 +181,13 @@ export default function PodcastPage() {
|
|||||||
rel="noopener noreferrer"
|
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"
|
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
|
RSS Feed
|
||||||
</a>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Now Playing */}
|
|
||||||
{currentEpisode && (
|
{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="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">
|
<div className="flex items-center gap-4">
|
||||||
@ -213,26 +195,19 @@ export default function PodcastPage() {
|
|||||||
onClick={togglePlay}
|
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"
|
className="w-12 h-12 bg-blue-600 hover:bg-blue-700 rounded-full flex items-center justify-center text-white transition-colors"
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{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>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-slate-100 truncate">
|
<h3 className="font-semibold text-gray-900 dark:text-slate-100 truncate">
|
||||||
{currentEpisode.title}
|
{currentEpisode.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block text-sm text-gray-500 dark:text-slate-400">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@ -252,20 +227,16 @@ export default function PodcastPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Episode List */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-semibold mb-4">Episodes</h2>
|
<h2 className="text-xl font-semibold mb-4">Episodes</h2>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12">
|
<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>
|
</div>
|
||||||
) : episodes.length === 0 ? (
|
) : episodes.length === 0 ? (
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-xl dark:bg-slate-900">
|
<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-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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
episodes.map((episode) => (
|
episodes.map((episode) => (
|
||||||
@ -282,19 +253,14 @@ export default function PodcastPage() {
|
|||||||
onClick={() => handlePlay(episode)}
|
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"
|
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 ? (
|
{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>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-1">
|
<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}
|
{episode.title}
|
||||||
</Link>
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
@ -302,7 +268,7 @@ export default function PodcastPage() {
|
|||||||
{episode.description}
|
{episode.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-slate-500">
|
<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>·</span>
|
||||||
<span>{formatDuration(episode.audioDuration)}</span>
|
<span>{formatDuration(episode.audioDuration)}</span>
|
||||||
{episode.tags && episode.tags.length > 0 && (
|
{episode.tags && episode.tags.length > 0 && (
|
||||||
@ -321,12 +287,10 @@ export default function PodcastPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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>
|
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-2">
|
||||||
<p className="text-sm text-gray-600 dark:text-slate-400 mb-4">
|
Subscribe to the Podcast
|
||||||
Get the latest tech digest delivered to your favorite podcast app.
|
</h3>
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<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">
|
<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}
|
{rssUrl}
|
||||||
@ -340,15 +304,6 @@ export default function PodcastPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
</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
|
* Podcast RSS feed generation for article-level digests.
|
||||||
* Generates RSS 2.0 with iTunes extensions for podcast distribution
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabase = createClient(
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
);
|
||||||
|
|
||||||
export interface PodcastEpisode {
|
export interface PodcastEpisode {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
content: string;
|
|
||||||
date: string;
|
date: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
audioUrl?: string;
|
audioUrl?: string;
|
||||||
@ -34,53 +32,28 @@ export interface PodcastConfig {
|
|||||||
explicit: boolean;
|
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 = {
|
export const DEFAULT_CONFIG: PodcastConfig = {
|
||||||
title: "OpenClaw Daily Digest",
|
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",
|
author: "OpenClaw",
|
||||||
email: "podcast@openclaw.ai",
|
email: "podcast@openclaw.ai",
|
||||||
category: "Technology",
|
category: "Technology",
|
||||||
language: "en-US",
|
language: "en-US",
|
||||||
websiteUrl: "https://blog-backup-two.vercel.app",
|
websiteUrl: defaultSiteUrl(),
|
||||||
imageUrl: "https://blog-backup-two.vercel.app/podcast-cover.png",
|
imageUrl: `${defaultSiteUrl()}/podcast-cover.png`,
|
||||||
explicit: false,
|
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 {
|
export function formatDuration(seconds: number): string {
|
||||||
const hours = Math.floor(seconds / 3600);
|
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
const hours = Math.floor(safe / 3600);
|
||||||
const secs = seconds % 60;
|
const minutes = Math.floor((safe % 3600) / 60);
|
||||||
|
const secs = safe % 60;
|
||||||
|
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "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")}`;
|
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date to RFC 2822 format for RSS
|
|
||||||
*/
|
|
||||||
export function formatRFC2822(date: Date | string | number): string {
|
export function formatRFC2822(date: Date | string | number): string {
|
||||||
const d = new Date(date);
|
return new Date(date).toUTCString();
|
||||||
return d.toUTCString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape XML special characters
|
|
||||||
*/
|
|
||||||
export function escapeXml(text: string): string {
|
export function escapeXml(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@ -108,22 +74,17 @@ export function escapeXml(text: string): string {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function generateGuid(articleId: string): string {
|
||||||
* Generate unique GUID for episode
|
return `openclaw-article-audio-${articleId}`;
|
||||||
*/
|
|
||||||
export function generateGuid(episodeId: string): string {
|
|
||||||
return `openclaw-digest-${episodeId}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function fetchEpisodes(limit = 50): Promise<PodcastEpisode[]> {
|
||||||
* Fetch episodes from database
|
|
||||||
*/
|
|
||||||
export async function fetchEpisodes(limit: number = 50): Promise<PodcastEpisode[]> {
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.select("id, date, content, timestamp, audio_url, audio_duration, tags")
|
.select("id, title, summary, digest_date, published_at, audio_url, audio_duration, tags")
|
||||||
.not("audio_url", "is", null) // Only episodes with audio
|
.eq("is_published", true)
|
||||||
.order("timestamp", { ascending: false })
|
.not("audio_url", "is", null)
|
||||||
|
.order("published_at", { ascending: false })
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -131,45 +92,49 @@ export async function fetchEpisodes(limit: number = 50): Promise<PodcastEpisode[
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (data || []).map(item => ({
|
return (data || []).map((item) => {
|
||||||
id: item.id,
|
const timestamp = Number.isFinite(new Date(item.published_at).getTime())
|
||||||
title: extractTitle(item.content),
|
? new Date(item.published_at).getTime()
|
||||||
description: extractExcerpt(item.content),
|
: Date.now();
|
||||||
content: item.content,
|
|
||||||
date: item.date,
|
return {
|
||||||
timestamp: item.timestamp,
|
id: item.id,
|
||||||
audioUrl: item.audio_url,
|
title: item.title,
|
||||||
audioDuration: item.audio_duration || 300, // Default 5 min if not set
|
description: item.summary,
|
||||||
tags: item.tags || [],
|
date: item.digest_date || item.published_at.slice(0, 10),
|
||||||
}));
|
timestamp,
|
||||||
|
audioUrl: item.audio_url || undefined,
|
||||||
|
audioDuration: item.audio_duration || 300,
|
||||||
|
tags: item.tags || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function generateRSS(
|
||||||
* Generate RSS feed XML
|
episodes: PodcastEpisode[],
|
||||||
*/
|
config: PodcastConfig = DEFAULT_CONFIG
|
||||||
export function generateRSS(episodes: PodcastEpisode[], config: PodcastConfig = DEFAULT_CONFIG): string {
|
): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const lastBuildDate = episodes.length > 0
|
const lastBuildDate = episodes.length > 0 ? new Date(episodes[0].timestamp) : now;
|
||||||
? new Date(episodes[0].timestamp)
|
|
||||||
: now;
|
|
||||||
|
|
||||||
const itemsXml = episodes.map(episode => {
|
const itemsXml = episodes
|
||||||
const guid = generateGuid(episode.id);
|
.map((episode) => {
|
||||||
const pubDate = formatRFC2822(episode.timestamp);
|
const guid = generateGuid(episode.id);
|
||||||
const duration = formatDuration(episode.audioDuration || 300);
|
const pubDate = formatRFC2822(episode.timestamp);
|
||||||
const enclosureUrl = escapeXml(episode.audioUrl || "");
|
const duration = formatDuration(episode.audioDuration || 300);
|
||||||
const title = escapeXml(episode.title);
|
const enclosureUrl = escapeXml(episode.audioUrl || "");
|
||||||
const description = escapeXml(episode.description);
|
const title = escapeXml(episode.title);
|
||||||
const keywords = episode.tags?.join(", ") || "technology, ai, programming";
|
const description = escapeXml(episode.description);
|
||||||
|
const keywords = episode.tags?.join(", ") || "technology, ai, programming";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<item>
|
<item>
|
||||||
<title>${title}</title>
|
<title>${title}</title>
|
||||||
<description>${description}</description>
|
<description>${description}</description>
|
||||||
<pubDate>${pubDate}</pubDate>
|
<pubDate>${pubDate}</pubDate>
|
||||||
<guid isPermaLink="false">${guid}</guid>
|
<guid isPermaLink="false">${guid}</guid>
|
||||||
<link>${escapeXml(`${config.websiteUrl}/?post=${episode.id}`)}</link>
|
<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:title>${title}</itunes:title>
|
||||||
<itunes:author>${escapeXml(config.author)}</itunes:author>
|
<itunes:author>${escapeXml(config.author)}</itunes:author>
|
||||||
<itunes:summary>${description}</itunes:summary>
|
<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:explicit>${config.explicit ? "yes" : "no"}</itunes:explicit>
|
||||||
<itunes:keywords>${escapeXml(keywords)}</itunes:keywords>
|
<itunes:keywords>${escapeXml(keywords)}</itunes:keywords>
|
||||||
</item>`;
|
</item>`;
|
||||||
}).join("\n");
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
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">
|
<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 { 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 supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
@ -21,6 +23,25 @@ export interface UploadResult {
|
|||||||
/**
|
/**
|
||||||
* Ensure the podcast-audio bucket exists
|
* 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> {
|
export async function ensureBucket(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Check if bucket exists
|
// Check if bucket exists
|
||||||
@ -62,6 +83,13 @@ export async function uploadAudio(
|
|||||||
filename: string,
|
filename: string,
|
||||||
contentType: string = "audio/mpeg"
|
contentType: string = "audio/mpeg"
|
||||||
): Promise<UploadResult> {
|
): 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();
|
await ensureBucket();
|
||||||
|
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
|
|||||||
205
src/lib/tts.ts
205
src/lib/tts.ts
@ -2,66 +2,52 @@
|
|||||||
* Text-to-Speech Service
|
* Text-to-Speech Service
|
||||||
* Supports multiple TTS providers: Piper (local/free), OpenAI (API/paid)
|
* Supports multiple TTS providers: Piper (local/free), OpenAI (API/paid)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface TTSOptions {
|
export interface TTSOptions {
|
||||||
provider?: "piper" | "openai" | "macsay";
|
provider?: "piper" | "openai" | "macsay" | "kokoro";
|
||||||
voice?: string;
|
voice?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TTSResult {
|
export interface TTSResult {
|
||||||
audioBuffer: Buffer;
|
audioBuffer: Buffer;
|
||||||
duration: number; // estimated duration in seconds
|
duration: number; // estimated duration in seconds
|
||||||
format: string;
|
format: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Abstract TTS Provider Interface
|
// Abstract TTS Provider Interface
|
||||||
interface TTSProvider {
|
interface TTSProvider {
|
||||||
synthesize(text: string, options?: TTSOptions): Promise<TTSResult>;
|
synthesize(text: string, options?: TTSOptions): Promise<TTSResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Piper TTS Provider (Local, Free)
|
// Piper TTS Provider (Local, Free)
|
||||||
class PiperProvider implements TTSProvider {
|
class PiperProvider implements TTSProvider {
|
||||||
private modelPath: string;
|
private modelPath: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Default model path - can be configured via env
|
// Default model path - can be configured via env
|
||||||
this.modelPath = process.env.PIPER_MODEL_PATH || "./models/en_US-lessac-medium.onnx";
|
this.modelPath = process.env.PIPER_MODEL_PATH || "./models/en_US-lessac-medium.onnx";
|
||||||
}
|
}
|
||||||
|
|
||||||
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
|
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
|
||||||
const { exec } = await import("child_process");
|
const { exec } = await import("child_process");
|
||||||
const { promisify } = await import("util");
|
const { promisify } = await import("util");
|
||||||
const fs = await import("fs");
|
const fs = await import("fs");
|
||||||
const path = await import("path");
|
const path = await import("path");
|
||||||
const os = await import("os");
|
const os = await import("os");
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
// Create temp directory for output
|
// Create temp directory for output
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "piper-"));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "piper-"));
|
||||||
const outputPath = path.join(tempDir, "output.wav");
|
const outputPath = path.join(tempDir, "output.wav");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if piper is installed
|
// Check if piper is installed
|
||||||
await execAsync("which piper || which piper-tts");
|
await execAsync("which piper || which piper-tts");
|
||||||
|
|
||||||
// Run Piper TTS
|
// Run Piper TTS
|
||||||
const piperCmd = `echo ${JSON.stringify(text)} | piper --model "${this.modelPath}" --output_file "${outputPath}"`;
|
const piperCmd = `echo ${JSON.stringify(text)} | piper --model "${this.modelPath}" --output_file "${outputPath}"`;
|
||||||
await execAsync(piperCmd, { timeout: 60000 });
|
await execAsync(piperCmd, { timeout: 60000 });
|
||||||
|
|
||||||
// Read the output file
|
// Read the output file
|
||||||
const audioBuffer = fs.readFileSync(outputPath);
|
const audioBuffer = fs.readFileSync(outputPath);
|
||||||
|
|
||||||
// Estimate duration (rough: ~150 words per minute, ~5 chars per word)
|
// Estimate duration (rough: ~150 words per minute, ~5 chars per word)
|
||||||
const wordCount = text.split(/\s+/).length;
|
const wordCount = text.split(/\s+/).length;
|
||||||
const estimatedDuration = Math.ceil((wordCount / 150) * 60);
|
const estimatedDuration = Math.ceil((wordCount / 150) * 60);
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
fs.unlinkSync(outputPath);
|
fs.unlinkSync(outputPath);
|
||||||
fs.rmdirSync(tempDir);
|
fs.rmdirSync(tempDir);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
audioBuffer,
|
audioBuffer,
|
||||||
duration: estimatedDuration,
|
duration: estimatedDuration,
|
||||||
@ -77,23 +63,19 @@ class PiperProvider implements TTSProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI TTS Provider (API-based, paid)
|
// OpenAI TTS Provider (API-based, paid)
|
||||||
class OpenAIProvider implements TTSProvider {
|
class OpenAIProvider implements TTSProvider {
|
||||||
private apiKey: string;
|
private apiKey: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.apiKey = process.env.OPENAI_API_KEY || "";
|
this.apiKey = process.env.OPENAI_API_KEY || "";
|
||||||
if (!this.apiKey) {
|
if (!this.apiKey) {
|
||||||
throw new Error("OPENAI_API_KEY not configured");
|
throw new Error("OPENAI_API_KEY not configured");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
|
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
|
||||||
const voice = options?.voice || "alloy";
|
const voice = options?.voice || "alloy";
|
||||||
const model = options?.model || "tts-1";
|
const model = options?.model || "tts-1";
|
||||||
const speed = options?.speed || 1.0;
|
const speed = options?.speed || 1.0;
|
||||||
|
|
||||||
const response = await fetch("https://api.openai.com/v1/audio/speech", {
|
const response = await fetch("https://api.openai.com/v1/audio/speech", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@ -108,18 +90,14 @@ class OpenAIProvider implements TTSProvider {
|
|||||||
response_format: "mp3",
|
response_format: "mp3",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.text();
|
const error = await response.text();
|
||||||
throw new Error(`OpenAI TTS API error: ${response.status} ${error}`);
|
throw new Error(`OpenAI TTS API error: ${response.status} ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioBuffer = Buffer.from(await response.arrayBuffer());
|
const audioBuffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|
||||||
// Estimate duration (rough calculation)
|
// Estimate duration (rough calculation)
|
||||||
const wordCount = text.split(/\s+/).length;
|
const wordCount = text.split(/\s+/).length;
|
||||||
const estimatedDuration = Math.ceil((wordCount / 150) * 60);
|
const estimatedDuration = Math.ceil((wordCount / 150) * 60);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
audioBuffer,
|
audioBuffer,
|
||||||
duration: estimatedDuration,
|
duration: estimatedDuration,
|
||||||
@ -127,8 +105,62 @@ class OpenAIProvider implements TTSProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// macOS say command (built-in, basic quality)
|
// 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 {
|
class MacSayProvider implements TTSProvider {
|
||||||
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
|
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
|
||||||
const { exec } = await import("child_process");
|
const { exec } = await import("child_process");
|
||||||
@ -136,28 +168,21 @@ class MacSayProvider implements TTSProvider {
|
|||||||
const fs = await import("fs");
|
const fs = await import("fs");
|
||||||
const path = await import("path");
|
const path = await import("path");
|
||||||
const os = await import("os");
|
const os = await import("os");
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "say-"));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "say-"));
|
||||||
const outputPath = path.join(tempDir, "output.aiff");
|
const outputPath = path.join(tempDir, "output.aiff");
|
||||||
const voice = options?.voice || "Samantha";
|
const voice = options?.voice || "Samantha";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use macOS say command
|
// Use macOS say command
|
||||||
const sayCmd = `say -v "${voice}" -o "${outputPath}" ${JSON.stringify(text)}`;
|
const sayCmd = `say -v "${voice}" -o "${outputPath}" ${JSON.stringify(text)}`;
|
||||||
await execAsync(sayCmd, { timeout: 120000 });
|
await execAsync(sayCmd, { timeout: 120000 });
|
||||||
|
|
||||||
const audioBuffer = fs.readFileSync(outputPath);
|
const audioBuffer = fs.readFileSync(outputPath);
|
||||||
|
|
||||||
// Estimate duration
|
// Estimate duration
|
||||||
const wordCount = text.split(/\s+/).length;
|
const wordCount = text.split(/\s+/).length;
|
||||||
const estimatedDuration = Math.ceil((wordCount / 150) * 60);
|
const estimatedDuration = Math.ceil((wordCount / 150) * 60);
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
fs.unlinkSync(outputPath);
|
fs.unlinkSync(outputPath);
|
||||||
fs.rmdirSync(tempDir);
|
fs.rmdirSync(tempDir);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
audioBuffer,
|
audioBuffer,
|
||||||
duration: estimatedDuration,
|
duration: estimatedDuration,
|
||||||
@ -172,12 +197,10 @@ class MacSayProvider implements TTSProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main TTS Service
|
// Main TTS Service
|
||||||
export class TTSService {
|
export class TTSService {
|
||||||
private provider: TTSProvider;
|
private provider: TTSProvider;
|
||||||
|
constructor(provider: "piper" | "openai" | "macsay" | "kokoro" = "openai") {
|
||||||
constructor(provider: "piper" | "openai" | "macsay" = "openai") {
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "piper":
|
case "piper":
|
||||||
this.provider = new PiperProvider();
|
this.provider = new PiperProvider();
|
||||||
@ -185,13 +208,15 @@ export class TTSService {
|
|||||||
case "macsay":
|
case "macsay":
|
||||||
this.provider = new MacSayProvider();
|
this.provider = new MacSayProvider();
|
||||||
break;
|
break;
|
||||||
|
case "kokoro":
|
||||||
|
this.provider = new KokoroProvider();
|
||||||
|
break;
|
||||||
case "openai":
|
case "openai":
|
||||||
default:
|
default:
|
||||||
this.provider = new OpenAIProvider();
|
this.provider = new OpenAIProvider();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
|
async synthesize(text: string, options?: TTSOptions): Promise<TTSResult> {
|
||||||
// Clean up text for TTS (remove markdown, URLs, etc.)
|
// Clean up text for TTS (remove markdown, URLs, etc.)
|
||||||
const cleanText = this.cleanTextForTTS(text);
|
const cleanText = this.cleanTextForTTS(text);
|
||||||
@ -201,10 +226,8 @@ export class TTSService {
|
|||||||
const truncatedText = cleanText.length > maxChars
|
const truncatedText = cleanText.length > maxChars
|
||||||
? cleanText.substring(0, maxChars) + "... That's all for today."
|
? cleanText.substring(0, maxChars) + "... That's all for today."
|
||||||
: cleanText;
|
: cleanText;
|
||||||
|
|
||||||
return this.provider.synthesize(truncatedText, options);
|
return this.provider.synthesize(truncatedText, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanTextForTTS(text: string): string {
|
private cleanTextForTTS(text: string): string {
|
||||||
return text
|
return text
|
||||||
// Remove markdown headers
|
// Remove markdown headers
|
||||||
@ -226,23 +249,123 @@ export class TTSService {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience function
|
// Convenience function
|
||||||
export async function generateSpeech(
|
export async function generateSpeech(
|
||||||
text: string,
|
text: string,
|
||||||
options?: TTSOptions
|
options?: TTSOptions
|
||||||
): Promise<TTSResult> {
|
): 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);
|
const service = new TTSService(provider);
|
||||||
return service.synthesize(text, options);
|
return service.synthesize(text, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy-loaded default service instance
|
// Lazy-loaded default service instance
|
||||||
let _tts: TTSService | null = null;
|
let _tts: TTSService | null = null;
|
||||||
export function getTTS(): TTSService {
|
export function getTTS(): TTSService {
|
||||||
if (!_tts) {
|
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);
|
_tts = new TTSService(provider);
|
||||||
}
|
}
|
||||||
return _tts;
|
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 { createClient } from "@supabase/supabase-js";
|
||||||
import { generateSpeech } from "@/lib/tts";
|
import { generateSpeech } from "@/lib/tts";
|
||||||
import { uploadAudio } from "@/lib/storage";
|
import { uploadAudio } from "@/lib/storage";
|
||||||
import { extractTitle } from "@/lib/podcast";
|
import { articleNarrationText } from "@/lib/articles";
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
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);
|
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
async function generateTTSForPost(postId: string) {
|
async function generateTTSForPost(postId: string) {
|
||||||
console.log(`Generating TTS for post ${postId}...`);
|
console.log(`Generating TTS for article ${postId}...`);
|
||||||
|
|
||||||
// Fetch post from database
|
// Fetch article from database
|
||||||
const { data: post, error: fetchError } = await supabase
|
const { data: article, error: fetchError } = await supabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.select("id, content, date, audio_url")
|
.select("id, title, summary, digest_date, audio_url")
|
||||||
.eq("id", postId)
|
.eq("id", postId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (fetchError || !post) {
|
if (fetchError || !article) {
|
||||||
console.error("Error fetching post:", fetchError);
|
console.error("Error fetching article:", fetchError);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (post.audio_url) {
|
if (article.audio_url) {
|
||||||
console.log("Post already has audio:", post.audio_url);
|
console.log("Article already has audio:", article.audio_url);
|
||||||
console.log("Use --force to regenerate");
|
console.log("Use --force to regenerate");
|
||||||
|
|
||||||
if (!process.argv.includes("--force")) {
|
if (!process.argv.includes("--force")) {
|
||||||
@ -41,11 +41,14 @@ async function generateTTSForPost(postId: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Generating speech...");
|
console.log("Generating speech...");
|
||||||
const title = extractTitle(post.content);
|
console.log(`Title: ${article.title}`);
|
||||||
console.log(`Title: ${title}`);
|
const narration = articleNarrationText({
|
||||||
|
title: article.title,
|
||||||
|
summary: article.summary,
|
||||||
|
});
|
||||||
|
|
||||||
const { audioBuffer, duration, format } = await generateSpeech(post.content, {
|
const { audioBuffer, duration, format } = await generateSpeech(narration, {
|
||||||
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai",
|
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay" | "kokoro") || "openai",
|
||||||
voice: process.env.TTS_VOICE || "alloy",
|
voice: process.env.TTS_VOICE || "alloy",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -55,15 +58,19 @@ async function generateTTSForPost(postId: string) {
|
|||||||
const ext = format === "audio/wav" ? "wav" :
|
const ext = format === "audio/wav" ? "wav" :
|
||||||
format === "audio/aiff" ? "aiff" : "mp3";
|
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}...`);
|
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}`);
|
console.log(`Uploaded to: ${url}`);
|
||||||
|
|
||||||
// Update database
|
// Update database
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await supabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.update({
|
.update({
|
||||||
audio_url: url,
|
audio_url: url,
|
||||||
audio_duration: duration,
|
audio_duration: duration,
|
||||||
@ -86,49 +93,57 @@ async function generateTTSForPost(postId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function generateTTSForAllMissing() {
|
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
|
const { data: articles, error } = await supabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.select("id, content, date, audio_url")
|
.select("id, title, summary, digest_date, published_at, audio_url")
|
||||||
|
.eq("is_published", true)
|
||||||
.is("audio_url", null)
|
.is("audio_url", null)
|
||||||
.order("timestamp", { ascending: false });
|
.order("published_at", { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Error fetching posts:", error);
|
console.error("Error fetching articles:", error);
|
||||||
process.exit(1);
|
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 || []) {
|
for (const article of articles || []) {
|
||||||
console.log(`\n--- Processing post ${post.id} ---`);
|
console.log(`\n--- Processing article ${article.id} ---`);
|
||||||
try {
|
try {
|
||||||
const title = extractTitle(post.content);
|
console.log(`Title: ${article.title}`);
|
||||||
console.log(`Title: ${title}`);
|
const narration = articleNarrationText({
|
||||||
|
title: article.title,
|
||||||
|
summary: article.summary,
|
||||||
|
});
|
||||||
|
|
||||||
const { audioBuffer, duration, format } = await generateSpeech(post.content, {
|
const { audioBuffer, duration, format } = await generateSpeech(narration, {
|
||||||
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai",
|
provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay" | "kokoro") || "openai",
|
||||||
voice: process.env.TTS_VOICE || "alloy",
|
voice: process.env.TTS_VOICE || "alloy",
|
||||||
});
|
});
|
||||||
|
|
||||||
const ext = format === "audio/wav" ? "wav" :
|
const ext = format === "audio/wav" ? "wav" :
|
||||||
format === "audio/aiff" ? "aiff" : "mp3";
|
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);
|
const { url } = await uploadAudio(audioBuffer, filename, format);
|
||||||
|
|
||||||
await supabase
|
await supabase
|
||||||
.from("blog_messages")
|
.from("blog_articles")
|
||||||
.update({
|
.update({
|
||||||
audio_url: url,
|
audio_url: url,
|
||||||
audio_duration: duration,
|
audio_duration: duration,
|
||||||
})
|
})
|
||||||
.eq("id", post.id);
|
.eq("id", article.id);
|
||||||
|
|
||||||
console.log(`✅ Generated: ${url} (${duration}s)`);
|
console.log(`✅ Generated: ${url} (${duration}s)`);
|
||||||
} catch (error) {
|
} 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);
|
await generateTTSForPost(postId);
|
||||||
} else {
|
} else {
|
||||||
console.log("Usage:");
|
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 <article_id> # Generate for specific article");
|
||||||
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 --all # Generate for all missing articles");
|
||||||
console.log(" npx ts-node src/scripts/generate-tts.ts <id> --force # Regenerate existing");
|
console.log(" npx ts-node src/scripts/generate-tts.ts <id> --force # Regenerate existing");
|
||||||
process.exit(1);
|
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