diff --git a/README.md b/README.md index 5cdf5e2..4a3f5f2 100644 --- a/README.md +++ b/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 `` per article + +## Local run ```bash npm install npm run dev ``` -Dev server runs on `http://localhost:3002`. +Dev server: `http://localhost:3002` -## Environment variables +## Required env vars -Set these in `.env.local`: +Set in `.env.local`: - `NEXT_PUBLIC_SUPABASE_URL` - `NEXT_PUBLIC_SUPABASE_ANON_KEY` -- `SUPABASE_SERVICE_ROLE_KEY` (used by external scripts/tools if needed) +- `SUPABASE_SERVICE_ROLE_KEY` - `CRON_API_KEY` -## Public vs admin access +## Database setup -- Public blog (`/`) is open to everyone. -- Reading messages (`GET /api/messages`) is public. -- Admin UI (`/admin`) requires a signed-in Supabase user. -- If not signed in, `/admin` redirects to `/login`. -- Write APIs (`POST/DELETE /api/messages`) require either: - - a valid Supabase user bearer token, or - - `x-api-key: ` (for automation/cron). +Run migrations in `supabase/migrations` (or apply `schema.sql` for a fresh setup). -## Login flow +Main table migration: -1. Open `/login` -2. Sign in with a Supabase Auth email/password user -3. You are redirected to `/admin` +- `supabase/migrations/20260303_create_blog_articles.sql` -## Digest automation endpoint +## API surface -- `POST /api/digest` requires `x-api-key: ` -- Used for cron-based digest publishing +### Primary article API -## RSS feeds +- `GET /api/articles` (public) +- `POST /api/articles` (auth required) +- `GET /api/articles/:id` (public) +- `PATCH /api/articles/:id` (auth required) +- `DELETE /api/articles/:id` (auth required) -- `GET /api/rss` - Standard digest RSS feed for blog/article consumers -- `GET /api/podcast/rss` - Podcast RSS feed (audio episodes) +Auth for write routes: -## MP3 Audio Hosting +- Supabase bearer token, or +- `x-api-key: ` -Upload and host MP3 files for individual blog posts. +### Digest ingestion API -### Features -- Upload audio files up to 50MB -- Custom audio player with play/pause, seek, and volume controls -- Audio indicator in admin post list -- Automatic duration estimation -- Stored in Supabase Storage (`podcast-audio` bucket) +- `POST /api/digest` (requires `x-api-key`) +- Accepts `date` + `articles[]`, inserts one `blog_articles` row per article -### Usage -1. Go to `/admin` and log in -2. Click "Edit" on any post -3. Scroll to "Audio Attachment" section -4. Upload an MP3 file -5. Save changes +### RSS -### API Endpoints -- `GET /api/audio?postId={id}` - Get audio info for a post -- `POST /api/audio` - Upload audio file (requires auth) -- `DELETE /api/audio` - Remove audio from post (requires auth) +- `GET /api/rss` - article feed (one item per article) +- `GET /api/podcast/rss` - podcast feed for rows with `audio_url` -See `docs/MP3_AUDIO_FEATURE.md` for full documentation. +### Legacy compatibility + +- `GET/POST/DELETE /api/messages` is kept as a compatibility shim over `blog_articles`. + +## UI pages + +- `/` public article feed +- `/podcast` audio episode list from article rows with audio +- `/admin` authenticated article CRUD + audio upload +- `/login` Supabase auth + +## CLI + +Use project CLI: + +```bash +./scripts/blog.sh --help +``` + +Recommended commands: + +- `article-add` +- `article-list` +- `article-delete` +- `rss` + +## Audio + +Audio files are stored in Supabase Storage bucket `podcast-audio` and linked via: + +- `blog_articles.audio_url` +- `blog_articles.audio_duration` diff --git a/memory/heartbeat-state.json b/memory/heartbeat-state.json new file mode 100644 index 0000000..ff776ea --- /dev/null +++ b/memory/heartbeat-state.json @@ -0,0 +1 @@ +{"lastChecks": {"email": 1772500752, "calendar": 1772503603, "weather": null, "missionControl": 1772503603, "git": 1772503603}} diff --git a/schema.sql b/schema.sql index 81a2651..95a49f4 100644 --- a/schema.sql +++ b/schema.sql @@ -1,24 +1,46 @@ --- Blog Backup Database Schema --- Run this SQL in Supabase Dashboard SQL Editor +-- Blog Backup database schema (article-first). +-- Apply in Supabase SQL Editor for a fresh setup. --- Add audio URL column for storing podcast audio file URLs -ALTER TABLE blog_messages -ADD COLUMN IF NOT EXISTS audio_url TEXT; +CREATE TABLE IF NOT EXISTS public.blog_articles ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + summary TEXT NOT NULL, + source_url TEXT, + digest_date DATE, + published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + tags TEXT[] NOT NULL DEFAULT '{}'::TEXT[], + audio_url TEXT, + audio_duration INTEGER, + is_published BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); --- Add audio duration column (in seconds) -ALTER TABLE blog_messages -ADD COLUMN IF NOT EXISTS audio_duration INTEGER; +CREATE INDEX IF NOT EXISTS idx_blog_articles_published_at + ON public.blog_articles (published_at DESC); --- Create index for faster queries on posts with audio -CREATE INDEX IF NOT EXISTS idx_blog_messages_audio -ON blog_messages(audio_url) -WHERE audio_url IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_blog_articles_digest_date + ON public.blog_articles (digest_date DESC); -COMMENT ON COLUMN blog_messages.audio_url IS 'Public URL to the audio file in Supabase Storage'; -COMMENT ON COLUMN blog_messages.audio_duration IS 'Audio duration in seconds (estimated from file size)'; +CREATE INDEX IF NOT EXISTS idx_blog_articles_tags + ON public.blog_articles USING GIN (tags); --- Verify columns were added -SELECT column_name, data_type -FROM information_schema.columns -WHERE table_name = 'blog_messages' -ORDER BY ordinal_position; \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_blog_articles_audio + ON public.blog_articles (audio_url) + WHERE audio_url IS NOT NULL; + +COMMENT ON TABLE public.blog_articles IS + 'One row per article. digest_date groups rows into a daily digest.'; + +COMMENT ON COLUMN public.blog_articles.summary IS + 'Structured article summary body shown in UI and RSS.'; + +COMMENT ON COLUMN public.blog_articles.digest_date IS + 'Logical daily digest date (YYYY-MM-DD). Multiple articles can share one date.'; + +COMMENT ON COLUMN public.blog_articles.is_published IS + 'Controls public visibility in /api/articles and RSS.'; + +SELECT table_name, column_name, data_type +FROM information_schema.columns +WHERE table_name IN ('blog_articles') +ORDER BY table_name, ordinal_position; diff --git a/scripts/blog.sh b/scripts/blog.sh index 993ad3d..28e8ac0 100755 --- a/scripts/blog.sh +++ b/scripts/blog.sh @@ -1,296 +1,444 @@ #!/bin/bash -# Blog Backup CLI - Main Entry Point -# Usage: ./blog.sh [args] -# -# Commands: -# post Create a new digest post -# list List digests -# get Get a specific digest by ID -# delete Delete a digest -# search Search digests by content -# status Check if digest exists for a date -# health Check API health -# -# Environment: -# BLOG_API_URL Blog Backup API URL (default: https://blog-backup-two.vercel.app/api) -# BLOG_MACHINE_TOKEN Machine token for API auth (required) - set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DEFAULT_BLOG_API_URL="https://blog.twisteddevices.com/api" -# Set default API URL -export BLOG_API_URL="${BLOG_API_URL:-https://blog-backup-two.vercel.app/api}" - -# Check for machine token -if [[ -z "${BLOG_MACHINE_TOKEN:-}" ]]; then - # Try to load from .env.local - if [[ -f "$PROJECT_ROOT/.env.local" ]]; then - source "$PROJECT_ROOT/.env.local" - fi - - # Check if CRON_API_KEY is available - if [[ -n "${CRON_API_KEY:-}" ]]; then - export BLOG_MACHINE_TOKEN="$CRON_API_KEY" - fi +if [[ -f "$PROJECT_ROOT/.env.local" ]]; then + source "$PROJECT_ROOT/.env.local" +fi + +export BLOG_API_URL="${BLOG_API_URL:-$DEFAULT_BLOG_API_URL}" + +if [[ -z "${BLOG_MACHINE_TOKEN:-}" ]]; then + if [[ -n "${CRON_API_KEY:-}" ]]; then + export BLOG_MACHINE_TOKEN="$CRON_API_KEY" + fi fi -# Source the skill library source "${HOME}/.agents/skills/blog-backup/lib/blog.sh" -# Show usage +url_encode() { + jq -rn --arg value "${1:-}" '$value|@uri' +} + +API_STATUS="" +API_BODY="" + +api_call() { + local method="$1" + local endpoint="$2" + local payload="${3:-}" + local url="${BLOG_API_URL}${endpoint}" + + local curl_args=(-sS -X "$method" "$url") + local tmp_body + local http_code + tmp_body="$(mktemp)" + + if [[ -n "${BLOG_MACHINE_TOKEN:-}" ]]; then + curl_args+=(-H "x-api-key: ${BLOG_MACHINE_TOKEN}") + fi + + if [[ -n "$payload" ]]; then + curl_args+=(-H "Content-Type: application/json" -d "$payload") + fi + + if ! http_code="$(curl "${curl_args[@]}" -o "$tmp_body" -w "%{http_code}")"; then + rm -f "$tmp_body" + return 1 + fi + + API_STATUS="$http_code" + API_BODY="$(cat "$tmp_body")" + rm -f "$tmp_body" + return 0 +} + +is_json() { + jq -e . >/dev/null 2>&1 <<<"${1:-}" +} + +is_http_success() { + local code="${1:-0}" + [[ "$code" =~ ^[0-9]+$ ]] && (( code >= 200 && code < 300 )) +} + +print_api_error() { + local endpoint="$1" + + echo "❌ API request failed: ${BLOG_API_URL}${endpoint} (HTTP ${API_STATUS:-unknown})" >&2 + + if is_json "$API_BODY"; then + local message + message="$(jq -r '.error // .message // empty' <<<"$API_BODY")" + if [[ -n "$message" ]]; then + echo "Error: $message" >&2 + fi + echo "$API_BODY" | jq . >&2 + return + fi + + echo "Response (non-JSON):" >&2 + printf '%s\n' "$API_BODY" | head -c 800 >&2 + echo >&2 + + if [[ "${BLOG_API_URL}" == "https://blog.twisteddevices.com/api" && "$API_BODY" == *"This page could not be found."* ]]; then + echo "Hint: /api/articles is not deployed there yet. For local testing use BLOG_API_URL=http://localhost:3002/api" >&2 + fi +} + usage() { - cat << 'EOF' + cat <<'EOF' Blog Backup CLI Usage: ./blog.sh [args] -Commands: - post [args] Create a new digest post - --date YYYY-MM-DD Date for the digest (required) - --content "..." Content in markdown (required) - --tags '["tag1","tag2"]' Tags as JSON array (default: ["daily-digest"]) +Environment: + BLOG_API_URL API base (default: https://blog.twisteddevices.com/api) + For local dev use: BLOG_API_URL=http://localhost:3002/api + BLOG_MACHINE_TOKEN Optional machine token (falls back to CRON_API_KEY from .env.local) - list [args] List digests - --limit N Max results (default: 20) - --since YYYY-MM-DD Filter by date - --json Output as JSON +Modern article commands: + article-add [args] Add one article (recommended) + --title "..." Article title (required) + --summary "..." Article summary (required) + --url "https://..." Source URL (optional) + --date YYYY-MM-DD Digest date (default: today) + --published-at ISO8601 Published timestamp (optional) + --tags "a,b,c" Comma-separated tags (optional) + --json Output full JSON - get 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 Delete a digest by ID + article-delete 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 Get digest by ID + delete Delete digest by ID search Search digests by content - --limit N Max results (default: 20) - - status Check if digest exists for a date (YYYY-MM-DD) - + status Check if digest exists for date health Check API health - -Examples: - ./blog.sh post --date 2026-02-26 --content "# Daily Digest" --tags '["AI", "iOS"]' - ./blog.sh list --limit 10 - ./blog.sh get 1234567890 - ./blog.sh search "OpenClaw" - ./blog.sh status 2026-02-26 - -Environment Variables: - BLOG_API_URL Blog Backup API URL - BLOG_MACHINE_TOKEN Machine token for API auth - EOF } -# Post command -handle_post() { - local DATE="" - local CONTENT="" - local TAGS='["daily-digest"]' +handle_article_add() { + local TITLE="" + local SUMMARY="" + local SOURCE_URL="" + local DIGEST_DATE="$(date +%F)" + local PUBLISHED_AT="" + local TAGS="" + local JSON_OUTPUT=false - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - --date) - DATE="$2" - shift 2 - ;; - --content) - CONTENT="$2" - shift 2 - ;; - --tags) - TAGS="$2" - shift 2 - ;; - *) - echo "Unknown option: $1" >&2 - shift - ;; - esac - done - - # Validate required fields - if [[ -z "$DATE" ]]; then - echo "Usage: ./blog.sh post --date --content [--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 " >&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 " >&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 [--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 " >&2 - exit 1 - fi - - if blog_post_status "$DATE"; then - echo "✅ Digest exists for $DATE" - else - echo "❌ No digest found for $DATE" - fi -} - -# Health command -handle_health() { - echo "=== API Health Check ===" - local RESULT - RESULT=$(blog_health) - - if [[ -n "$RESULT" ]]; then - echo "✅ API is accessible" - echo "$RESULT" | jq . 2>/dev/null || echo "$RESULT" - else - echo "❌ API is not responding" >&2 - exit 1 - fi -} - -# Main command dispatcher -main() { - local cmd="${1:-}" - shift || true - - case "$cmd" in - post) - handle_post "$@" - ;; - list) - handle_list "$@" - ;; - get) - handle_get "$@" - ;; - delete) - handle_delete "$@" - ;; - search) - handle_search "$@" - ;; - status) - handle_status "$@" - ;; - health) - handle_health - ;; - help|--help|-h) - usage - ;; - *) - if [[ -z "$cmd" ]]; then - usage - exit 1 - fi - echo "Unknown command: $cmd" >&2 - usage - exit 1 - ;; + while [[ $# -gt 0 ]]; do + case "$1" in + --title) TITLE="${2:-}"; shift 2 ;; + --summary) SUMMARY="${2:-}"; shift 2 ;; + --url) SOURCE_URL="${2:-}"; shift 2 ;; + --date) DIGEST_DATE="${2:-}"; shift 2 ;; + --published-at) PUBLISHED_AT="${2:-}"; shift 2 ;; + --tags) TAGS="${2:-}"; shift 2 ;; + --json) JSON_OUTPUT=true; shift ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; esac + done + + if [[ -z "$TITLE" || -z "$SUMMARY" ]]; then + echo "Usage: ./blog.sh article-add --title --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 "$@" diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index dcf0041..c98ccdf 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,21 +1,21 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { createClient, type User } from "@supabase/supabase-js"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import { AudioUpload } from "@/components/AudioUpload"; -interface Message { +interface Article { id: string; - date: string; - content: string; - timestamp: number; - tags?: string[]; - audio_url?: string | null; - audio_duration?: number | null; + title: string; + summary: string; + sourceUrl: string | null; + digestDate: string | null; + publishedAt: string; + tags: string[]; + audioUrl: string | null; + audioDuration: number | null; } type Theme = "light" | "dark"; @@ -25,32 +25,38 @@ const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); +function toTagArray(input: string): string[] { + return input + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + export default function AdminPage() { const router = useRouter(); - const [messages, setMessages] = useState<Message[]>([]); + const [articles, setArticles] = useState<Article[]>([]); const [loading, setLoading] = useState(true); const [user, setUser] = useState<User | null>(null); - const [editingPost, setEditingPost] = useState<Message | null>(null); - const [editContent, setEditContent] = useState(""); - const [editDate, setEditDate] = useState(""); - const [editTags, setEditTags] = useState(""); - const [showCreateModal, setShowCreateModal] = useState(false); - const [newContent, setNewContent] = useState(""); - const [newDate, setNewDate] = useState(""); - const [newTags, setNewTags] = useState("daily-digest"); const [theme, setTheme] = useState<Theme>(() => { if (typeof window === "undefined") { return "light"; } - - const storedTheme = localStorage.getItem("theme"); - if (storedTheme === "light" || storedTheme === "dark") { - return storedTheme; + const stored = localStorage.getItem("theme"); + if (stored === "light" || stored === "dark") { + return stored; } - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editing, setEditing] = useState<Article | null>(null); + + const [title, setTitle] = useState(""); + const [summary, setSummary] = useState(""); + const [sourceUrl, setSourceUrl] = useState(""); + const [digestDate, setDigestDate] = useState(""); + const [tagsText, setTagsText] = useState("daily-digest"); + useEffect(() => { document.documentElement.classList.toggle("dark", theme === "dark"); localStorage.setItem("theme", theme); @@ -58,25 +64,36 @@ export default function AdminPage() { useEffect(() => { async function runAuthCheck() { - const { data: { user } } = await supabase.auth.getUser(); - if (!user) { + const { + data: { user: activeUser }, + } = await supabase.auth.getUser(); + if (!activeUser) { router.replace("/login"); return; } - setUser(user); + setUser(activeUser); } - - runAuthCheck(); + void runAuthCheck(); }, [router]); useEffect(() => { if (user) { - fetchMessages(); + void fetchArticles(); } }, [user]); + const sortedArticles = useMemo( + () => + [...articles].sort( + (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime() + ), + [articles] + ); + async function getAuthHeaders() { - const { data: { session } } = await supabase.auth.getSession(); + const { + data: { session }, + } = await supabase.auth.getSession(); if (!session?.access_token) { throw new Error("No active session"); } @@ -92,128 +109,125 @@ export default function AdminPage() { router.replace("/login"); } - async function fetchMessages() { + async function fetchArticles() { try { - const res = await fetch("/api/messages"); - const data = await res.json(); - if (Array.isArray(data)) { - setMessages(data.sort((a, b) => b.timestamp - a.timestamp)); - } + const res = await fetch("/api/articles?limit=500"); + const payload = (await res.json()) as { articles?: Article[] }; + setArticles(Array.isArray(payload.articles) ? payload.articles : []); } catch (error) { - console.error("Error fetching messages:", error); + console.error("Error fetching articles:", error); } finally { setLoading(false); } } - async function handleDelete(id: string) { - if (!confirm("Are you sure you want to delete this post?")) return; - - try { - const headers = await getAuthHeaders(); - const res = await fetch("/api/messages", { - method: "DELETE", - headers, - body: JSON.stringify({ id }), - }); - - if (res.ok) { - setMessages(messages.filter((m) => m.id !== id)); - } else if (res.status === 401) { - await handleUnauthorized(); - } else { - alert("Failed to delete post"); - } - } catch (error) { - console.error("Error deleting:", error); - await handleUnauthorized(); - alert("Error deleting post"); - } + function resetForm() { + setTitle(""); + setSummary(""); + setSourceUrl(""); + setDigestDate(""); + setTagsText("daily-digest"); } - function handleEdit(post: Message) { - setEditingPost(post); - setEditContent(post.content); - setEditDate(post.date); - setEditTags(post.tags?.join(", ") || ""); - } - - async function handleSaveEdit() { - if (!editingPost) return; - - try { - const headers = await getAuthHeaders(); - // Delete old post - const deleteRes = await fetch("/api/messages", { - method: "DELETE", - headers, - body: JSON.stringify({ id: editingPost.id }), - }); - - if (deleteRes.status === 401) { - await handleUnauthorized(); - return; - } - - if (!deleteRes.ok) { - alert("Failed to save changes"); - return; - } - - // Create new post with updated content - const res = await fetch("/api/messages", { - method: "POST", - headers, - body: JSON.stringify({ - content: editContent, - date: editDate, - tags: editTags.split(",").map((t) => t.trim()).filter(Boolean), - }), - }); - - if (res.ok) { - setEditingPost(null); - fetchMessages(); - } else if (res.status === 401) { - await handleUnauthorized(); - } else { - alert("Failed to save changes"); - } - } catch (error) { - console.error("Error saving:", error); - await handleUnauthorized(); - alert("Error saving changes"); - } + function loadFormFromArticle(article: Article) { + setTitle(article.title); + setSummary(article.summary); + setSourceUrl(article.sourceUrl || ""); + setDigestDate(article.digestDate || article.publishedAt.slice(0, 10)); + setTagsText(article.tags.join(", ")); } async function handleCreate() { try { const headers = await getAuthHeaders(); - const res = await fetch("/api/messages", { + const res = await fetch("/api/articles", { method: "POST", headers, body: JSON.stringify({ - content: newContent, - date: newDate, - tags: newTags.split(",").map((t) => t.trim()).filter(Boolean), + title, + summary, + sourceUrl: sourceUrl || null, + digestDate: digestDate || null, + tags: toTagArray(tagsText), }), }); if (res.ok) { setShowCreateModal(false); - setNewContent(""); - setNewDate(""); - setNewTags("daily-digest"); - fetchMessages(); - } else if (res.status === 401) { - await handleUnauthorized(); - } else { - alert("Failed to create post"); + resetForm(); + await fetchArticles(); + return; } + if (res.status === 401) { + await handleUnauthorized(); + return; + } + alert("Failed to create article"); } catch (error) { - console.error("Error creating:", error); + console.error("Error creating article:", error); + await handleUnauthorized(); + } + } + + async function handleSaveEdit() { + if (!editing) { + return; + } + + try { + const headers = await getAuthHeaders(); + const res = await fetch(`/api/articles/${editing.id}`, { + method: "PATCH", + headers, + body: JSON.stringify({ + title, + summary, + sourceUrl: sourceUrl || null, + digestDate: digestDate || null, + tags: toTagArray(tagsText), + }), + }); + + if (res.ok) { + setEditing(null); + resetForm(); + await fetchArticles(); + return; + } + if (res.status === 401) { + await handleUnauthorized(); + return; + } + alert("Failed to update article"); + } catch (error) { + console.error("Error updating article:", error); + await handleUnauthorized(); + } + } + + async function handleDelete(id: string) { + if (!confirm("Delete this article?")) { + return; + } + try { + const headers = await getAuthHeaders(); + const res = await fetch(`/api/articles/${id}`, { + method: "DELETE", + headers, + }); + + if (res.ok) { + setArticles((prev) => prev.filter((item) => item.id !== id)); + return; + } + if (res.status === 401) { + await handleUnauthorized(); + return; + } + alert("Failed to delete article"); + } catch (error) { + console.error("Error deleting article:", error); await handleUnauthorized(); - alert("Error creating post"); } } @@ -248,17 +262,22 @@ export default function AdminPage() { type="button" onClick={() => setTheme(theme === "dark" ? "light" : "dark")} className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800" - aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`} > {theme === "dark" ? "Light mode" : "Dark mode"} </button> <button - onClick={() => setShowCreateModal(true)} + onClick={() => { + resetForm(); + setShowCreateModal(true); + }} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" > - + New Post + + New Article </button> - <Link href="/" className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800"> + <Link + href="/" + className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800" + > View Site </Link> <button @@ -274,21 +293,19 @@ export default function AdminPage() { <main className="max-w-6xl mx-auto px-4 py-8"> <div className="bg-white rounded-lg shadow border border-gray-200 dark:bg-slate-900 dark:border-slate-800"> <div className="px-6 py-4 border-b border-gray-200 dark:border-slate-800"> - <h2 className="text-lg font-semibold"> - All Posts ({messages.length}) - </h2> + <h2 className="text-lg font-semibold">All Articles ({articles.length})</h2> </div> <div className="divide-y divide-gray-200 dark:divide-slate-800"> - {messages.map((post) => ( - <div key={post.id} className="p-6 hover:bg-gray-50 dark:hover:bg-slate-800/40"> - <div className="flex items-start justify-between"> + {sortedArticles.map((article) => ( + <div key={article.id} className="p-6 hover:bg-gray-50 dark:hover:bg-slate-800/40"> + <div className="flex items-start justify-between gap-4"> <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2 mb-2"> + <div className="flex items-center gap-2 mb-2 flex-wrap"> <span className="text-sm text-gray-500 dark:text-slate-400"> - {new Date(post.timestamp).toLocaleDateString()} + {new Date(article.publishedAt).toLocaleString()} </span> - {post.tags?.map((tag) => ( + {(article.tags || []).map((tag) => ( <span key={tag} className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded dark:bg-blue-900/40 dark:text-blue-200" @@ -296,28 +313,41 @@ export default function AdminPage() { {tag} </span> ))} - {post.audio_url && ( - <span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded flex items-center gap-1 dark:bg-green-900/40 dark:text-green-200"> - <svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"> - <path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.983 5.983 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.414z" clipRule="evenodd" /> - </svg> + {article.audioUrl && ( + <span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded dark:bg-green-900/40 dark:text-green-200"> Audio </span> )} </div> - <div className="text-sm text-gray-600 dark:text-slate-300 line-clamp-2"> - {post.content.substring(0, 200)}... - </div> + <h3 className="text-lg font-semibold text-gray-900 dark:text-slate-100 mb-1"> + {article.title} + </h3> + <p className="text-sm text-gray-600 dark:text-slate-300 line-clamp-2"> + {article.summary} + </p> + {article.sourceUrl && ( + <a + href={article.sourceUrl} + target="_blank" + rel="noreferrer" + className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" + > + {article.sourceUrl} + </a> + )} </div> - <div className="flex gap-2 ml-4"> + <div className="flex gap-2"> <button - onClick={() => handleEdit(post)} + onClick={() => { + setEditing(article); + loadFormFromArticle(article); + }} className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800" > Edit </button> <button - onClick={() => handleDelete(post.id)} + onClick={() => void handleDelete(article.id)} className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700" > Delete @@ -330,142 +360,113 @@ export default function AdminPage() { </div> </main> - {/* Edit Modal */} - {editingPost && ( + {(showCreateModal || editing) && ( <div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"> <div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto border border-gray-200 dark:bg-slate-900 dark:border-slate-800"> <div className="p-6 border-b border-gray-200 dark:border-slate-800"> - <h3 className="text-lg font-semibold">Edit Post</h3> + <h3 className="text-lg font-semibold">{editing ? "Edit Article" : "New Article"}</h3> </div> <div className="p-6 space-y-4"> <div> - <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Date</label> + <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200"> + Title + </label> <input type="text" - value={editDate} - onChange={(e) => setEditDate(e.target.value)} + value={title} + onChange={(e) => setTitle(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100" /> </div> <div> - <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Tags (comma separated)</label> - <input - type="text" - value={editTags} - onChange={(e) => setEditTags(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100" - /> - </div> - <div> - <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Content (Markdown)</label> + <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200"> + Summary + </label> <textarea - value={editContent} - onChange={(e) => setEditContent(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded h-64 font-mono text-sm bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100" + value={summary} + onChange={(e) => setSummary(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded h-56 bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100" /> </div> - <div> - <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Preview</label> - <div className="border border-gray-300 rounded p-4 prose max-w-none max-h-64 overflow-auto bg-white dark:bg-slate-950 dark:border-slate-700"> - <ReactMarkdown remarkPlugins={[remarkGfm]}> - {editContent} - </ReactMarkdown> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200"> + Digest Date + </label> + <input + type="date" + value={digestDate} + onChange={(e) => setDigestDate(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100" + /> + </div> + <div> + <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200"> + Tags (comma separated) + </label> + <input + type="text" + value={tagsText} + onChange={(e) => setTagsText(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100" + /> </div> </div> - - {/* Audio Upload Section */} - <div className="border-t border-gray-200 pt-4 dark:border-slate-800"> - <label className="block text-sm font-medium mb-3 text-gray-700 dark:text-slate-200"> - 🎧 Audio Attachment + <div> + <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200"> + Source URL </label> - <AudioUpload - postId={editingPost.id} - existingAudioUrl={editingPost.audio_url} - onUploadSuccess={(url, duration) => { - setEditingPost({ - ...editingPost, - audio_url: url, - audio_duration: duration, - }); - }} - onDeleteSuccess={() => { - setEditingPost({ - ...editingPost, - audio_url: null, - audio_duration: null, - }); - }} + <input + type="url" + value={sourceUrl} + onChange={(e) => setSourceUrl(e.target.value)} + placeholder="https://..." + className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100" /> </div> - </div> - <div className="p-6 border-t border-gray-200 dark:border-slate-800 flex justify-end gap-2"> - <button - onClick={() => setEditingPost(null)} - className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800" - > - Cancel - </button> - <button - onClick={handleSaveEdit} - className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" - > - Save Changes - </button> - </div> - </div> - </div> - )} - {/* Create Modal */} - {showCreateModal && ( - <div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"> - <div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-auto border border-gray-200 dark:bg-slate-900 dark:border-slate-800"> - <div className="p-6 border-b border-gray-200 dark:border-slate-800"> - <h3 className="text-lg font-semibold">New Post</h3> - </div> - <div className="p-6 space-y-4"> - <div> - <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Date</label> - <input - type="text" - value={newDate} - onChange={(e) => setNewDate(e.target.value)} - placeholder="2026-02-22" - className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 placeholder:text-gray-400 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500" - /> - </div> - <div> - <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Tags (comma separated)</label> - <input - type="text" - value={newTags} - onChange={(e) => setNewTags(e.target.value)} - placeholder="daily-digest, news" - className="w-full px-3 py-2 border border-gray-300 rounded bg-white text-gray-900 placeholder:text-gray-400 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500" - /> - </div> - <div> - <label className="block text-sm font-medium mb-1 text-gray-700 dark:text-slate-200">Content (Markdown)</label> - <textarea - value={newContent} - onChange={(e) => setNewContent(e.target.value)} - placeholder="# Post Title\n\nYour content here..." - className="w-full px-3 py-2 border border-gray-300 rounded h-64 font-mono text-sm bg-white text-gray-900 placeholder:text-gray-400 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-100 dark:placeholder:text-slate-500" - /> - </div> + {editing && ( + <div className="border-t border-gray-200 pt-4 dark:border-slate-800"> + <label className="block text-sm font-medium mb-3 text-gray-700 dark:text-slate-200"> + Audio Attachment + </label> + <AudioUpload + postId={editing.id} + existingAudioUrl={editing.audioUrl} + onUploadSuccess={(url, duration) => { + setEditing({ + ...editing, + audioUrl: url, + audioDuration: duration, + }); + }} + onDeleteSuccess={() => { + setEditing({ + ...editing, + audioUrl: null, + audioDuration: null, + }); + }} + /> + </div> + )} </div> <div className="p-6 border-t border-gray-200 dark:border-slate-800 flex justify-end gap-2"> <button - onClick={() => setShowCreateModal(false)} + onClick={() => { + setShowCreateModal(false); + setEditing(null); + resetForm(); + }} className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-100 dark:border-slate-700 dark:hover:bg-slate-800" > Cancel </button> <button - onClick={handleCreate} + onClick={() => void (editing ? handleSaveEdit() : handleCreate())} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" > - Create Post + {editing ? "Save Changes" : "Create Article"} </button> </div> </div> diff --git a/src/app/api/articles/[id]/route.ts b/src/app/api/articles/[id]/route.ts new file mode 100644 index 0000000..4df1c49 --- /dev/null +++ b/src/app/api/articles/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/articles/route.ts b/src/app/api/articles/route.ts new file mode 100644 index 0000000..5245bf6 --- /dev/null +++ b/src/app/api/articles/route.ts @@ -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 }); + } +} diff --git a/src/app/api/audio/route.ts b/src/app/api/audio/route.ts index 9694426..1b16ec1 100644 --- a/src/app/api/audio/route.ts +++ b/src/app/api/audio/route.ts @@ -49,8 +49,8 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Post ID required" }, { status: 400 }); } - const { data, error } = await supabase - .from("blog_messages") + const { data, error } = await serviceSupabase + .from("blog_articles") .select("id, audio_url, audio_duration") .eq("id", postId) .single(); @@ -107,9 +107,9 @@ export async function POST(request: Request) { } // Get post info for filename - const { data: post, error: postError } = await supabase - .from("blog_messages") - .select("date") + const { data: post, error: postError } = await serviceSupabase + .from("blog_articles") + .select("digest_date, published_at") .eq("id", postId) .single(); @@ -123,7 +123,9 @@ export async function POST(request: Request) { // Create filename const timestamp = Date.now(); - const sanitizedDate = post.date.replace(/[^a-zA-Z0-9-]/g, ""); + const fallbackDate = String(post.published_at || "").slice(0, 10); + const dateForFile = post.digest_date || fallbackDate || new Date().toISOString().slice(0, 10); + const sanitizedDate = dateForFile.replace(/[^a-zA-Z0-9-]/g, ""); const extension = file.name.split(".").pop() || "mp3"; const filename = `upload-${sanitizedDate}-${postId}-${timestamp}.${extension}`; @@ -165,7 +167,7 @@ export async function POST(request: Request) { // Update database with audio info const { error: updateError } = await serviceSupabase - .from("blog_messages") + .from("blog_articles") .update({ audio_url: urlData.publicUrl, audio_duration: estimatedDuration, @@ -215,8 +217,8 @@ export async function DELETE(request: Request) { } // Get current audio info - const { data: post, error: fetchError } = await supabase - .from("blog_messages") + const { data: post, error: fetchError } = await serviceSupabase + .from("blog_articles") .select("audio_url") .eq("id", postId) .single(); @@ -227,17 +229,21 @@ export async function DELETE(request: Request) { // Extract path from URL and delete from storage if (post?.audio_url) { - const url = new URL(post.audio_url); - const pathMatch = url.pathname.match(/\/object\/public\/[^/]+\/(.+)/); - if (pathMatch) { - const path = decodeURIComponent(pathMatch[1]); - await serviceSupabase.storage.from(BUCKET_NAME).remove([path]); + try { + const url = new URL(post.audio_url); + const pathMatch = url.pathname.match(/\/object\/public\/[^/]+\/(.+)/); + if (pathMatch) { + const path = decodeURIComponent(pathMatch[1]); + await serviceSupabase.storage.from(BUCKET_NAME).remove([path]); + } + } catch { + // Local-file URLs or non-URL paths are valid in some deployments. } } // Clear audio fields in database const { error: updateError } = await serviceSupabase - .from("blog_messages") + .from("blog_articles") .update({ audio_url: null, audio_duration: null, @@ -259,4 +265,4 @@ export async function DELETE(request: Request) { { status: 500 } ); } -} \ No newline at end of file +} diff --git a/src/app/api/digest/route.ts b/src/app/api/digest/route.ts index c254663..c6ab73d 100644 --- a/src/app/api/digest/route.ts +++ b/src/app/api/digest/route.ts @@ -1,123 +1,117 @@ -/** - * Daily Digest API Endpoint - * Saves digest content and optionally generates TTS audio - */ - +import crypto from "node:crypto"; import { NextResponse } from "next/server"; import { createClient } from "@supabase/supabase-js"; -import { generateSpeech } from "@/lib/tts"; -import { uploadAudio } from "@/lib/storage"; -import { extractTitle, extractExcerpt } from "@/lib/podcast"; +import { + isMissingBlogArticlesTableError, + normalizeTags, + toDigestDate, +} from "@/lib/articles"; -const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! -); +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; const serviceSupabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ); +interface DigestArticleInput { + title?: string; + summary?: string; + sourceUrl?: string | null; + tags?: string[]; + publishedAt?: string | null; +} + +interface DigestRequestBody { + date?: string; + tags?: string[]; + articles?: DigestArticleInput[]; +} + export async function POST(request: Request) { const apiKey = request.headers.get("x-api-key"); - const CRON_API_KEY = process.env.CRON_API_KEY; - - console.log("API Key from header:", apiKey); - console.log("CRON_API_KEY exists:", !!CRON_API_KEY); - - if (!CRON_API_KEY || apiKey !== CRON_API_KEY) { + const cronApiKey = process.env.CRON_API_KEY; + if (!cronApiKey || apiKey !== cronApiKey) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - - const { content, date, tags, generateAudio = true } = await request.json(); - - if (!content || !date) { - return NextResponse.json({ error: "Content and date required" }, { status: 400 }); - } - - const id = Date.now().toString(); - const newMessage: Record<string, any> = { - id, - date, - content, - timestamp: Date.now(), - tags: tags || ["daily-digest"] - }; - - // Only add audio fields if they exist in the table - // This handles schema differences between environments - - // Save message first (without audio) - const { error } = await serviceSupabase - .from("blog_messages") - .insert(newMessage); - - if (error) { - console.error("Error saving digest:", error); - return NextResponse.json({ error: "Failed to save" }, { status: 500 }); - } - // Generate TTS audio asynchronously (don't block response) - if (generateAudio && process.env.ENABLE_TTS === "true") { - // Use a non-blocking approach - generateTTSAsync(id, content, date).catch(err => { - console.error("TTS generation failed (async):", err); - }); - } - - return NextResponse.json({ - success: true, - id, - audioGenerated: generateAudio && process.env.ENABLE_TTS === "true" - }); -} - -/** - * Generate TTS audio and upload to storage - * This runs asynchronously after the main response is sent - */ -async function generateTTSAsync( - id: string, - content: string, - date: string -): Promise<void> { try { - console.log(`[TTS] Starting generation for digest ${id}...`); - - // Generate speech - const { audioBuffer, duration, format } = await generateSpeech(content, { - provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai", - voice: process.env.TTS_VOICE || "alloy", - }); + const body = (await request.json().catch(() => ({}))) as DigestRequestBody; + const digestDate = toDigestDate(body.date) || new Date().toISOString().slice(0, 10); + const baseTags = normalizeTags(body.tags); + const articles = Array.isArray(body.articles) ? body.articles : []; - // Determine file extension based on format - const ext = format === "audio/wav" ? "wav" : - format === "audio/aiff" ? "aiff" : "mp3"; - - // Create filename with date for organization - const filename = `digest-${date}-${id}.${ext}`; - - // Upload to Supabase Storage - const { url } = await uploadAudio(audioBuffer, filename, format); - - // Update database with audio URL - const { error: updateError } = await serviceSupabase - .from("blog_messages") - .update({ - audio_url: url, - audio_duration: duration, - }) - .eq("id", id); - - if (updateError) { - console.error("[TTS] Error updating database:", updateError); - throw updateError; + if (articles.length === 0) { + return NextResponse.json( + { error: "articles[] is required and must include at least one item" }, + { status: 400 } + ); } - console.log(`[TTS] Successfully generated audio for digest ${id}: ${url}`); + const now = Date.now(); + const rows = articles + .map((item, index) => { + const title = item.title?.trim() || ""; + const summary = item.summary?.trim() || ""; + if (!title || !summary) { + return null; + } + + const tags = normalizeTags([...(item.tags || []), ...baseTags, "daily-digest"]); + const publishedAt = + typeof item.publishedAt === "string" && item.publishedAt.trim() + ? new Date(item.publishedAt).toISOString() + : new Date(now - index).toISOString(); + + return { + id: crypto.randomUUID(), + title, + summary, + source_url: item.sourceUrl?.trim() || null, + digest_date: digestDate, + published_at: publishedAt, + tags, + audio_url: null as string | null, + audio_duration: null as number | null, + is_published: true, + }; + }) + .filter((item): item is NonNullable<typeof item> => !!item); + + if (rows.length === 0) { + return NextResponse.json( + { error: "Each article requires title and summary." }, + { status: 400 } + ); + } + + const { data, error } = await serviceSupabase + .from("blog_articles") + .insert(rows) + .select("id, title, summary, digest_date, source_url, tags, published_at"); + + if (error) { + throw error; + } + + return NextResponse.json({ + success: true, + digestDate, + inserted: rows.length, + articles: data || [], + }); } catch (error) { - console.error(`[TTS] Failed to generate audio for digest ${id}:`, error); - // Don't throw - we don't want to fail the whole request + console.error("POST /api/digest failed:", error); + if (isMissingBlogArticlesTableError(error)) { + return NextResponse.json( + { + error: + "Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.", + }, + { status: 500 } + ); + } + return NextResponse.json({ error: "Failed to create digest articles" }, { status: 500 }); } } diff --git a/src/app/api/messages/route.ts b/src/app/api/messages/route.ts index 8f7cf1b..3813bae 100644 --- a/src/app/api/messages/route.ts +++ b/src/app/api/messages/route.ts @@ -1,123 +1,218 @@ +import crypto from "node:crypto"; import { NextResponse } from "next/server"; import { createClient } from "@supabase/supabase-js"; +import { + articleDateFromRow, + articleLegacyContent, + articleTimestampFromRow, + isMissingBlogArticlesTableError, + normalizeTags, + toDigestDate, + toPublishedAt, +} from "@/lib/articles"; +import { requireWriteAccess } from "@/lib/auth"; -const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! -); +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; const serviceSupabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ); -function getBearerToken(request: Request): string | null { - const authorization = request.headers.get("authorization"); - if (!authorization?.startsWith("Bearer ")) { - return null; - } - - const token = authorization.slice("Bearer ".length).trim(); - return token.length > 0 ? token : null; +interface LegacyPayload { + title?: string; + summary?: string; + content?: string; + sourceUrl?: string | null; + date?: string | null; + publishedAt?: string | null; + tags?: string[]; } -async function getUserFromRequest(request: Request) { - const token = getBearerToken(request); - if (!token) { - return null; +function parseLegacyContent(raw: string): { + title: string; + summary: string; + sourceUrl: string | null; +} { + const lines = raw.replace(/\r/g, "").split("\n").map((line) => line.trim()); + const nonEmpty = lines.filter(Boolean); + const heading = nonEmpty.find((line) => /^#{1,2}\s+/.test(line)); + const title = (heading?.replace(/^#{1,2}\s+/, "") || nonEmpty[0] || "").trim(); + + let sourceUrl: string | null = null; + const summaryLines: string[] = []; + for (const line of nonEmpty) { + if (/^#{1,2}\s+/.test(line)) { + continue; + } + const sourceMatch = line.match(/^source\s*:\s*(https?:\/\/\S+)/i); + if (sourceMatch) { + sourceUrl = sourceMatch[1]; + continue; + } + summaryLines.push(line); } - const { data, error } = await supabase.auth.getUser(token); - if (error) { - console.error("Error validating auth token:", error.message); - return null; - } - - return data.user; + return { + title, + summary: summaryLines.join("\n").trim(), + sourceUrl, + }; } -const CRON_API_KEY = process.env.CRON_API_KEY; - -// GET is public (read-only) +// Legacy compatibility endpoint. Prefer /api/articles. export async function GET() { - const { data: messages, error } = await serviceSupabase - .from("blog_messages") - .select("id, date, content, timestamp, tags") - .order("timestamp", { ascending: false }); - + const { data, error } = await serviceSupabase + .from("blog_articles") + .select( + "id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration" + ) + .eq("is_published", true) + .order("published_at", { ascending: false }) + .limit(500); + if (error) { - console.error("Error fetching messages:", error); + if (isMissingBlogArticlesTableError(error)) { + return NextResponse.json( + { + error: + "Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.", + }, + { status: 500 } + ); + } return NextResponse.json({ error: "Failed to fetch messages" }, { status: 500 }); } - - return NextResponse.json(messages || []); + + const rows = (data || []).map((item) => ({ + id: item.id, + date: articleDateFromRow(item), + timestamp: articleTimestampFromRow(item), + tags: item.tags || [], + title: item.title, + summary: item.summary, + source_url: item.source_url, + sourceUrl: item.source_url, + audio_url: item.audio_url, + audio_duration: item.audio_duration, + content: articleLegacyContent({ + title: item.title, + summary: item.summary, + sourceUrl: item.source_url, + }), + })); + + return NextResponse.json(rows); } -// POST requires auth OR API key for cron jobs export async function POST(request: Request) { - const apiKey = request.headers.get("x-api-key"); - - const hasCronAccess = !!CRON_API_KEY && apiKey === CRON_API_KEY; - if (!hasCronAccess) { - const user = await getUserFromRequest(request); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + const allowed = await requireWriteAccess(request); + if (!allowed) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - - const { content, date, tags } = await request.json(); - - if (!content || !date) { - return NextResponse.json({ error: "Content and date required" }, { status: 400 }); + + const body = (await request.json().catch(() => ({}))) as LegacyPayload; + + let title = body.title?.trim() || ""; + let summary = body.summary?.trim() || ""; + let sourceUrl = body.sourceUrl?.trim() || null; + + if ((!title || !summary) && typeof body.content === "string" && body.content.trim()) { + const parsed = parseLegacyContent(body.content); + title = title || parsed.title; + summary = summary || parsed.summary; + sourceUrl = sourceUrl || parsed.sourceUrl; } - - const newMessage = { - id: Date.now().toString(), - date, - content, - timestamp: Date.now(), - tags: tags || [], + + if (!title || !summary) { + return NextResponse.json( + { error: "title and summary required (or content in legacy mode)" }, + { status: 400 } + ); + } + + const row = { + id: crypto.randomUUID(), + title, + summary, + source_url: sourceUrl, + digest_date: toDigestDate(body.date), + published_at: toPublishedAt(body.publishedAt), + tags: normalizeTags(body.tags), + audio_url: null as string | null, + audio_duration: null as number | null, + is_published: true, }; - - const { error } = await serviceSupabase - .from("blog_messages") - .insert(newMessage); - + + const { data, error } = await serviceSupabase + .from("blog_articles") + .insert(row) + .select( + "id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration" + ) + .single(); + if (error) { - console.error("Error saving message:", error); + if (isMissingBlogArticlesTableError(error)) { + return NextResponse.json( + { + error: + "Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.", + }, + { status: 500 } + ); + } return NextResponse.json({ error: "Failed to save message" }, { status: 500 }); } - - return NextResponse.json(newMessage); + + const result = { + id: data.id, + date: articleDateFromRow(data), + timestamp: articleTimestampFromRow(data), + tags: data.tags || [], + title: data.title, + summary: data.summary, + source_url: data.source_url, + sourceUrl: data.source_url, + audio_url: data.audio_url, + audio_duration: data.audio_duration, + content: articleLegacyContent({ + title: data.title, + summary: data.summary, + sourceUrl: data.source_url, + }), + }; + + return NextResponse.json(result); } -// DELETE requires auth OR API key export async function DELETE(request: Request) { - const apiKey = request.headers.get("x-api-key"); - - const hasCronAccess = !!CRON_API_KEY && apiKey === CRON_API_KEY; - if (!hasCronAccess) { - const user = await getUserFromRequest(request); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + const allowed = await requireWriteAccess(request); + if (!allowed) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - - const { id } = await request.json(); - + + const body = (await request.json().catch(() => ({}))) as { id?: string }; + const id = body.id?.trim(); if (!id) { return NextResponse.json({ error: "ID required" }, { status: 400 }); } - - const { error } = await serviceSupabase - .from("blog_messages") - .delete() - .eq("id", id); - + + const { error } = await serviceSupabase.from("blog_articles").delete().eq("id", id); + if (error) { - console.error("Error deleting message:", error); + if (isMissingBlogArticlesTableError(error)) { + return NextResponse.json( + { + error: + "Missing table public.blog_articles. Run supabase/migrations/20260303_create_blog_articles.sql.", + }, + { status: 500 } + ); + } return NextResponse.json({ error: "Failed to delete message" }, { status: 500 }); } - + return NextResponse.json({ success: true }); } diff --git a/src/app/api/podcast/rss/route.ts b/src/app/api/podcast/rss/route.ts index f4749e4..532397d 100644 --- a/src/app/api/podcast/rss/route.ts +++ b/src/app/api/podcast/rss/route.ts @@ -6,6 +6,7 @@ import { NextResponse } from "next/server"; import { fetchEpisodes, generateRSS, DEFAULT_CONFIG } from "@/lib/podcast"; +import { isMissingBlogArticlesTableError } from "@/lib/articles"; export const dynamic = "force-dynamic"; // Always generate fresh RSS @@ -26,13 +27,16 @@ export async function GET() { }); } catch (error) { console.error("Error generating RSS feed:", error); + const description = isMissingBlogArticlesTableError(error) + ? "Missing table public.blog_articles. Apply migration 20260303_create_blog_articles.sql." + : "Error loading podcast feed. Please try again later."; return new NextResponse( `<?xml version="1.0" encoding="UTF-8"?> <rss version="2.0"> <channel> <title>OpenClaw Daily Digest - Error loading podcast feed. Please try again later. + ${description} `, { diff --git a/src/app/api/rss/route.ts b/src/app/api/rss/route.ts index 3fdf5fe..f9564cb 100644 --- a/src/app/api/rss/route.ts +++ b/src/app/api/rss/route.ts @@ -1,16 +1,18 @@ import { NextResponse } from "next/server"; import { createClient } from "@supabase/supabase-js"; -import { extractExcerpt, extractTitle } from "@/lib/podcast"; +import { + isMissingBlogArticlesTableError, + mapArticleRow, + type ArticleRow, +} from "@/lib/articles"; +export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -interface DigestRow { - id: string; - date: string | null; - content: string; - timestamp: number | string; - tags: string[] | null; -} +const serviceSupabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! +); function escapeXml(input: string): string { return input @@ -25,101 +27,69 @@ function cdataSafe(input: string): string { return input.replaceAll("]]>", "]]]]>"); } -function asRfc2822(value: string | number | null | undefined): string { - if (typeof value === "number") { - return new Date(value).toUTCString(); +function articleContentEncoded(summary: string, sourceUrl: string | null): string { + const lines = [summary]; + if (sourceUrl) { + lines.push(`Original source: ${sourceUrl}`); } - - if (typeof value === "string" && value.trim().length > 0) { - const numeric = Number(value); - if (Number.isFinite(numeric)) { - return new Date(numeric).toUTCString(); - } - return new Date(value).toUTCString(); - } - - return new Date().toUTCString(); -} - -function buildErrorFeed(baseUrl: string): string { - return ` - - - OpenClaw Daily Digest - ${escapeXml(baseUrl)} - Error loading digest RSS feed. Please try again later. - -`; + return lines.join("\n\n"); } export async function GET(request: Request) { - const url = new URL(request.url); - const baseUrl = - process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") || - `${url.protocol}//${url.host}`; - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseKey = - process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - if (!supabaseUrl || !supabaseKey) { - return new NextResponse(buildErrorFeed(baseUrl), { - status: 500, - headers: { - "Content-Type": "application/xml; charset=utf-8", - }, - }); - } - try { - const supabase = createClient(supabaseUrl, supabaseKey); + const url = new URL(request.url); + const baseUrl = + process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") || + `${url.protocol}//${url.host}`; - const { data, error } = await supabase - .from("blog_messages") - .select("id, date, content, timestamp, tags") - .order("timestamp", { ascending: false }) - .limit(100); + const { data, error } = await serviceSupabase + .from("blog_articles") + .select("id, title, summary, source_url, digest_date, published_at, tags, audio_url, audio_duration") + .eq("is_published", true) + .order("published_at", { ascending: false }) + .limit(150); if (error) { throw error; } - const rows = (data || []) as DigestRow[]; - - const itemsXml = rows - .map((row) => { - const title = escapeXml(extractTitle(row.content || "Daily Digest")); - const excerpt = escapeXml(extractExcerpt(row.content || "", 420)); - const pubDate = asRfc2822(row.timestamp || row.date); - const postUrl = `${baseUrl}/?post=${row.id}`; - const guid = `openclaw-digest-${row.id}`; - const categories = (row.tags || []) + const articles = ((data || []) as ArticleRow[]).map(mapArticleRow); + const itemsXml = articles + .map((article) => { + const link = article.sourceUrl || `${baseUrl}/?post=${article.id}`; + const categories = article.tags .map((tag) => `${escapeXml(tag)}`) .join(""); return ` - ${title} - ${escapeXml(postUrl)} - ${escapeXml(guid)} - ${pubDate} - ${excerpt} - + ${escapeXml(article.title)} + ${escapeXml(link)} + ${escapeXml(`openclaw-article-${article.id}`)} + ${new Date(article.publishedAt).toUTCString()} + ${escapeXml(article.summary)} + ${categories} `; }) .join("\n"); const lastBuildDate = - rows.length > 0 ? asRfc2822(rows[0].timestamp || rows[0].date) : new Date().toUTCString(); + articles.length > 0 + ? new Date(articles[0].publishedAt).toUTCString() + : new Date().toUTCString(); const feedXml = ` - + OpenClaw Daily Digest ${escapeXml(baseUrl)} - Daily digest posts from OpenClaw Blog Backup. + Daily article feed. One RSS item per article. en-us ${lastBuildDate} + hourly + 1 ${itemsXml} @@ -132,12 +102,18 @@ export async function GET(request: Request) { }, }); } catch (error) { - console.error("Error generating digest RSS feed:", error); - return new NextResponse(buildErrorFeed(baseUrl), { - status: 500, - headers: { - "Content-Type": "application/xml; charset=utf-8", - }, - }); + console.error("GET /api/rss failed:", error); + const description = isMissingBlogArticlesTableError(error) + ? "Missing table public.blog_articles. Apply migration 20260303_create_blog_articles.sql." + : "Error loading feed."; + return new NextResponse( + `OpenClaw Daily Digest${escapeXml( + description + )}`, + { + status: 500, + headers: { "Content-Type": "application/xml; charset=utf-8" }, + } + ); } } diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index a931d22..63bf406 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -4,7 +4,7 @@ */ import { NextResponse } from "next/server"; -import { generateSpeech, TTSOptions } from "@/lib/tts"; +import { generateSpeech, mixWithMusicLayered } from "@/lib/tts"; import { uploadAudio } from "@/lib/storage"; import { createClient } from "@supabase/supabase-js"; @@ -28,18 +28,35 @@ export async function POST(request: Request) { } try { - const { text, postId, options } = await request.json(); + const body = await request.json(); + const { text, postId, includeMusic, options } = body; if (!text) { return NextResponse.json({ error: "Text content is required" }, { status: 400 }); } // Generate TTS audio - const { audioBuffer, duration, format } = await generateSpeech(text, { - provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay") || "openai", + const ttsOptions = { + provider: (process.env.TTS_PROVIDER as "piper" | "openai" | "macsay" | "kokoro") || "openai", voice: process.env.TTS_VOICE || "alloy", ...options, - }); + }; + + let ttsResult = await generateSpeech(text, ttsOptions); + + // Mix with music if enabled + // Check includeMusic at top level OR in options + const musicEnabled = includeMusic ?? options?.includeMusic ?? process.env.ENABLE_PODCAST_MUSIC === "true"; + console.log("[TTS] includeMusic:", includeMusic, "options.includeMusic:", options?.includeMusic, "ENABLE_PODCAST_MUSIC:", process.env.ENABLE_PODCAST_MUSIC); + if (musicEnabled) { + console.log("[TTS] Mixing with intro/outro music..."); + ttsResult = await mixWithMusicLayered(ttsResult, { + introUrl: options?.introUrl, + }); + console.log("[TTS] Music mixing complete"); + } + + const { audioBuffer, duration, format } = ttsResult; // Determine file extension based on format const ext = format === "audio/wav" ? "wav" : @@ -57,7 +74,7 @@ export async function POST(request: Request) { // If postId provided, update the blog post with audio URL if (postId) { const { error: updateError } = await serviceSupabase - .from("blog_messages") + .from("blog_articles") .update({ audio_url: url, audio_duration: duration, @@ -100,7 +117,7 @@ export async function GET(request: Request) { } const { data, error } = await serviceSupabase - .from("blog_messages") + .from("blog_articles") .select("id, audio_url, audio_duration") .eq("id", postId) .single(); diff --git a/src/app/page.tsx b/src/app/page.tsx index 52a667a..536ecc2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,32 +1,52 @@ "use client"; -import { Suspense, useState, useEffect, useMemo } from "react"; +import { Suspense, useEffect, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { format } from "date-fns"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import Head from "next/head"; import Link from "next/link"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { AudioPlayer } from "@/components/AudioPlayer"; -// Helper to parse date string (YYYY-MM-DD) correctly across timezones -// Appends noon UTC to ensure consistent date display regardless of local timezone -function parseDate(dateStr: string): Date { - return new Date(`${dateStr}T12:00:00Z`); -} - -interface Message { - id: string; - date: string; - content: string; - timestamp: number; - tags?: string[]; - audio_url?: string; - audio_duration?: number; -} - type Theme = "light" | "dark"; +interface Article { + id: string; + title: string; + summary: string; + sourceUrl: string | null; + digestDate: string | null; + publishedAt: string; + tags: string[]; + audioUrl: string | null; + audioDuration: number | null; +} + +function parseDate(raw: string): Date { + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + return new Date(`${raw}T12:00:00Z`); + } + return new Date(raw); +} + +function displayDate(article: Article): string { + return article.digestDate || article.publishedAt.slice(0, 10); +} + +function plainExcerpt(input: string, maxLength = 180): string { + const text = input + .replace(/(\*\*|__|\*|_)/g, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/\n+/g, " ") + .trim(); + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength).trim()}...`; +} + export default function BlogPage() { return ( ([]); + + const [articles, setArticles] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); const [theme, setTheme] = useState("light"); useEffect(() => { - fetchMessages(); + void fetchArticles(); }, []); useEffect(() => { @@ -72,73 +92,60 @@ function BlogPageContent() { localStorage.setItem("theme", theme); }, [theme]); - async function fetchMessages() { + async function fetchArticles() { try { - const res = await fetch("/api/messages"); - const data = await res.json(); - setMessages(data); + const res = await fetch("/api/articles?limit=300"); + const payload = (await res.json()) as { articles?: Article[] }; + setArticles(Array.isArray(payload.articles) ? payload.articles : []); } catch (err) { - console.error("Failed to fetch:", err); + console.error("Failed to fetch articles:", err); + setArticles([]); } finally { setLoading(false); } } - // Get all unique tags const allTags = useMemo(() => { const tags = new Set(); - messages.forEach((m) => m.tags?.forEach((t) => tags.add(t))); - return Array.from(tags).sort(); - }, [messages]); - - // Filter messages - const filteredMessages = useMemo(() => { - let filtered = messages; - - if (selectedTag) { - filtered = filtered.filter((m) => m.tags?.includes(selectedTag)); + for (const article of articles) { + for (const tag of article.tags || []) { + tags.add(tag); + } } - - if (searchQuery) { - const query = searchQuery.toLowerCase(); - filtered = filtered.filter( - (m) => - m.content.toLowerCase().includes(query) || - m.tags?.some((t) => t.toLowerCase().includes(query)) + return [...tags].sort(); + }, [articles]); + + const filteredArticles = useMemo(() => { + let list = [...articles]; + + if (selectedTag) { + list = list.filter((item) => item.tags?.includes(selectedTag)); + } + + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + list = list.filter( + (item) => + item.title.toLowerCase().includes(q) || + item.summary.toLowerCase().includes(q) || + item.tags.some((tag) => tag.toLowerCase().includes(q)) ); } - - return filtered.sort((a, b) => b.timestamp - a.timestamp); - }, [messages, selectedTag, searchQuery]); - // Get featured post (most recent) - const featuredPost = filteredMessages[0]; - const regularPosts = filteredMessages.slice(1); + return list.sort( + (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime() + ); + }, [articles, selectedTag, searchQuery]); + const selectedPost = useMemo( - () => - selectedPostId - ? messages.find((message) => message.id === selectedPostId) ?? null - : null, - [messages, selectedPostId] + () => (selectedPostId ? articles.find((item) => item.id === selectedPostId) || null : null), + [articles, selectedPostId] ); - // Parse title from content - function getTitle(content: string): string { - const lines = content.split("\n"); - const titleLine = lines.find((l) => l.startsWith("# ") || l.startsWith("## ")); - return titleLine?.replace(/#{1,2}\s/, "") || "Daily Update"; - } - - // Get excerpt - function getExcerpt(content: string, maxLength: number = 150): string { - const plainText = content - .replace(/#{1,6}\s/g, "") - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/\*/g, "") - .replace(/\n/g, " "); - if (plainText.length <= maxLength) return plainText; - return plainText.substring(0, maxLength).trim() + "..."; - } + const featuredPost = !selectedTag && !searchQuery ? filteredArticles[0] : null; + const regularPosts = featuredPost + ? filteredArticles.filter((item) => item.id !== featuredPost.id) + : filteredArticles; if (loading) { return ( @@ -152,11 +159,13 @@ function BlogPageContent() { <> Daily Digest | OpenClaw Blog - +
- {/* Header */}
@@ -164,27 +173,28 @@ function BlogPageContent() {
📓
- Daily Digest + + Daily Digest + - +