diff --git a/README.md b/README.md index 9eaa796..4bc23e0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Daily Digest Blog -Next.js App Router blog with Supabase-backed posts and an authenticated admin panel. +Next.js App Router blog with Supabase-backed posts, authenticated admin panel, and MP3 audio hosting. ## Run locally @@ -40,3 +40,28 @@ Set these in `.env.local`: - `POST /api/digest` requires `x-api-key: ` - Used for cron-based digest publishing + +## MP3 Audio Hosting + +Upload and host MP3 files for individual blog posts. + +### 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) + +### 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 + +### 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) + +See `docs/MP3_AUDIO_FEATURE.md` for full documentation. diff --git a/docs/MP3_AUDIO_FEATURE.md b/docs/MP3_AUDIO_FEATURE.md new file mode 100644 index 0000000..a885935 --- /dev/null +++ b/docs/MP3_AUDIO_FEATURE.md @@ -0,0 +1,200 @@ +# MP3 Audio Hosting Feature + +This document describes the MP3 audio hosting functionality added to the blog-backup project. + +## Overview + +The blog now supports uploading and hosting MP3 audio files for individual blog posts. This allows you to: +- Upload custom MP3 files to any blog post +- Attach audio commentary, interviews, or supplementary content +- Automatically estimate audio duration +- Play audio directly in the blog post with a custom audio player + +## Features + +### 1. Supabase Storage Integration +- **Bucket**: `podcast-audio` (shared with podcast feature) +- **File Size Limit**: 50MB per file +- **Supported Formats**: MP3, WAV, AIFF +- **Public Access**: Files are publicly readable via signed URLs + +### 2. API Endpoints + +#### `GET /api/audio?postId={id}` +Retrieves audio information for a blog post. + +**Response:** +```json +{ + "hasAudio": true, + "audioUrl": "https://...", + "duration": 180 +} +``` + +#### `POST /api/audio` +Uploads an audio file and associates it with a blog post. + +**Request:** +- Content-Type: `multipart/form-data` +- Authorization: `Bearer {token}` +- Body: `file` (audio file), `postId` (string) + +**Response:** +```json +{ + "success": true, + "url": "https://...", + "path": "upload-2026-02-25-123456789-1740523456789.mp3", + "size": 5242880, + "estimatedDuration": 327 +} +``` + +#### `DELETE /api/audio` +Removes audio from a blog post and deletes the file from storage. + +**Request:** +- Authorization: `Bearer {token}` +- Body: `{ "postId": "..." }` + +### 3. Database Schema + +The `blog_messages` table now includes: +- `audio_url` (text, nullable) - Public URL to the audio file +- `audio_duration` (integer, nullable) - Estimated duration in seconds + +### 4. Components + +#### `AudioPlayer` +Located in `src/components/AudioPlayer.tsx` + +A custom audio player with: +- Play/pause controls +- Progress bar with seek functionality +- Volume control with mute toggle +- Time display (current / total) +- Dark mode support + +**Usage:** +```tsx +import { AudioPlayer } from "@/components/AudioPlayer"; + + +``` + +#### `CompactAudioPlayer` +A minimal version for inline use: +```tsx +import { CompactAudioPlayer } from "@/components/AudioPlayer"; + + +``` + +#### `AudioUpload` +Located in `src/components/AudioUpload.tsx` + +Upload component for the admin interface with: +- Drag-and-drop file selection +- File type validation +- File size validation (50MB max) +- Upload progress indicator +- Current audio preview +- Remove/delete functionality + +**Usage:** +```tsx +import { AudioUpload } from "@/components/AudioUpload"; + + {}} + onDeleteSuccess={() => {}} +/> +``` + +### 5. Admin Interface + +The admin dashboard (`/admin`) now includes: +- Audio indicator badge on posts with audio +- Audio upload section in the edit modal +- Preview player for uploaded audio +- Remove audio functionality + +## Usage Guide + +### Adding Audio to a Post + +1. Go to `/admin` and log in +2. Find the post you want to add audio to +3. Click "Edit" +4. Scroll to the "Audio Attachment" section +5. Click to upload or drag and drop an MP3 file +6. Wait for upload to complete +7. Save changes + +### Removing Audio from a Post + +1. Go to `/admin` and log in +2. Edit the post with audio +3. In the "Audio Attachment" section, click "Remove" +4. Confirm deletion +5. Save changes + +### Viewing Audio on the Blog + +- Posts with audio display a 🎧 icon in the admin list +- On the public blog, audio appears at the top of the post +- The custom audio player provides full playback controls + +## Technical Details + +### File Naming +Uploaded files are named with the pattern: +``` +upload-{date}-{postId}-{timestamp}.{ext} +``` + +Example: `upload-2026-02-25-123456789-1740523456789.mp3` + +### Duration Estimation +Since extracting exact MP3 duration server-side requires additional libraries, we estimate duration based on file size: +- Assumption: 128 kbps bitrate (standard for MP3) +- Formula: `duration = (fileSize / (128 * 1024 / 8)) * 60` + +The browser will display the actual duration once the audio file loads. + +### Storage Costs +- Supabase free tier includes 1GB storage +- At 128kbps, 1GB = ~17.5 hours of audio +- Podcast audio and uploaded MP3s share the same bucket + +## Future Enhancements + +Potential improvements: +1. **Exact Duration Extraction**: Use `music-metadata` library for accurate duration +2. **Multiple Audio Files**: Support for multiple audio tracks per post +3. **Audio Transcription**: Auto-generate transcripts from audio +4. **Waveform Visualization**: Display audio waveforms +5. **Download Button**: Allow users to download audio files + +## Troubleshooting + +### Upload fails +- Check that file is under 50MB +- Verify file is a valid audio format (MP3, WAV, AIFF) +- Ensure you're logged in with a valid session + +### Audio doesn't play +- Check browser console for errors +- Verify audio_url is accessible in the database +- Test the direct URL in a new tab + +### Duration shows 0:00 +- This is normal for the initial display +- The actual duration loads when the audio file is fetched +- Duration is estimated from file size for the initial display \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 901a4f2..dcf0041 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -6,6 +6,7 @@ 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 { id: string; @@ -13,6 +14,8 @@ interface Message { content: string; timestamp: number; tags?: string[]; + audio_url?: string | null; + audio_duration?: number | null; } type Theme = "light" | "dark"; @@ -293,6 +296,14 @@ export default function AdminPage() { {tag} ))} + {post.audio_url && ( + + + + + Audio + + )}
{post.content.substring(0, 200)}... @@ -361,6 +372,31 @@ export default function AdminPage() {
+ + {/* Audio Upload Section */} +
+ + { + setEditingPost({ + ...editingPost, + audio_url: url, + audio_duration: duration, + }); + }} + onDeleteSuccess={() => { + setEditingPost({ + ...editingPost, + audio_url: null, + audio_duration: null, + }); + }} + /> +
+ +
+
+ {formatTime(currentTime)} + / + {formatTime(audioDuration)} +
+ +
+ +
+
+
+ +
+ + +
+
+
+ ); +} + +interface CompactAudioPlayerProps { + url: string; + className?: string; +} + +export function CompactAudioPlayer({ url, className = "" }: CompactAudioPlayerProps) { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + + const togglePlay = () => { + const audio = audioRef.current; + if (!audio) return; + + if (isPlaying) { + audio.pause(); + } else { + audio.play(); + } + setIsPlaying(!isPlaying); + }; + + return ( +
+
+ ); +} \ No newline at end of file diff --git a/src/components/AudioUpload.tsx b/src/components/AudioUpload.tsx new file mode 100644 index 0000000..27a6098 --- /dev/null +++ b/src/components/AudioUpload.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useState, useRef } from "react"; + +interface AudioUploadProps { + postId: string; + existingAudioUrl?: string | null; + onUploadSuccess: (url: string, duration: number) => void; + onDeleteSuccess: () => void; +} + +export function AudioUpload({ + postId, + existingAudioUrl, + onUploadSuccess, + onDeleteSuccess +}: AudioUploadProps) { + const [isUploading, setIsUploading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const fileInputRef = useRef(null); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Reset states + setError(null); + setSuccess(null); + setUploadProgress(0); + + // Validate file type + if (!file.type.startsWith("audio/")) { + setError("Please select an audio file (MP3, WAV, etc.)"); + return; + } + + // Validate file size (50MB) + const maxSize = 50 * 1024 * 1024; + if (file.size > maxSize) { + setError("File size must be less than 50MB"); + return; + } + + setIsUploading(true); + + try { + // Simulate progress (actual fetch doesn't support progress well without XMLHttpRequest) + const progressInterval = setInterval(() => { + setUploadProgress((prev) => Math.min(prev + 10, 90)); + }, 200); + + // Get auth token + const { createClient } = await import("@supabase/supabase-js"); + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); + const { data: { session } } = await supabase.auth.getSession(); + + if (!session?.access_token) { + throw new Error("Not authenticated"); + } + + // Create form data + const formData = new FormData(); + formData.append("file", file); + formData.append("postId", postId); + + // Upload + const response = await fetch("/api/audio", { + method: "POST", + headers: { + Authorization: `Bearer ${session.access_token}`, + }, + body: formData, + }); + + clearInterval(progressInterval); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Upload failed"); + } + + const data = await response.json(); + setUploadProgress(100); + setSuccess(`Uploaded successfully! (${formatFileSize(file.size)})`); + onUploadSuccess(data.url, data.estimatedDuration); + + // Clear file input + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } catch (err) { + setError(err instanceof Error ? err.message : "Upload failed"); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + }; + + const handleDelete = async () => { + if (!confirm("Are you sure you want to remove the audio from this post?")) { + return; + } + + setIsDeleting(true); + setError(null); + setSuccess(null); + + try { + const { createClient } = await import("@supabase/supabase-js"); + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); + const { data: { session } } = await supabase.auth.getSession(); + + if (!session?.access_token) { + throw new Error("Not authenticated"); + } + + const response = await fetch("/api/audio", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ postId }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Delete failed"); + } + + setSuccess("Audio removed successfully"); + onDeleteSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : "Delete failed"); + } finally { + setIsDeleting(false); + } + }; + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }; + + return ( +
+ {/* Current Audio Status */} + {existingAudioUrl && ( +
+
+
+ + + + + Audio attached + +
+ +
+ +
+ )} + + {/* Upload Section */} + {!existingAudioUrl && ( +
+ + + + {/* Progress Bar */} + {isUploading && uploadProgress > 0 && ( +
+
+
+
+ + {uploadProgress}% + +
+ )} +
+ )} + + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Success Message */} + {success && ( +
+ {success} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/supabase/migrations/20250225_add_audio_columns.sql b/supabase/migrations/20250225_add_audio_columns.sql new file mode 100644 index 0000000..6c0bcd9 --- /dev/null +++ b/supabase/migrations/20250225_add_audio_columns.sql @@ -0,0 +1,24 @@ +-- Database Schema Update for MP3 Audio Support +-- This migration adds audio columns to the blog_messages table + +-- Add audio_url column (nullable text field for storing the public URL) +ALTER TABLE blog_messages +ADD COLUMN IF NOT EXISTS audio_url TEXT; + +-- Add audio_duration column (nullable integer for duration in seconds) +ALTER TABLE blog_messages +ADD COLUMN IF NOT EXISTS audio_duration INTEGER; + +-- 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; + +-- Storage bucket configuration (run in Supabase Dashboard SQL Editor) +-- Note: Bucket is created programmatically via the API + +-- RLS Policy for audio files (if needed in future) +-- For now, audio files are publicly readable via the podcast-audio bucket + +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)'; \ No newline at end of file