Add MP3 audio hosting feature with Supabase Storage integration
- Create /api/audio endpoint for upload/get/delete operations - Add AudioPlayer component with custom controls - Add AudioUpload component for admin interface - Update admin page with audio upload UI and indicators - Update main blog page to use new AudioPlayer component - Add database migration for audio_url and audio_duration columns - Add comprehensive documentation in docs/MP3_AUDIO_FEATURE.md - Update README with audio feature overview
This commit is contained in:
parent
dc060d2b1a
commit
3a81f88bfc
27
README.md
27
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: <CRON_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.
|
||||
|
||||
200
docs/MP3_AUDIO_FEATURE.md
Normal file
200
docs/MP3_AUDIO_FEATURE.md
Normal file
@ -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";
|
||||
|
||||
<AudioPlayer
|
||||
url="https://..."
|
||||
duration={180} // optional
|
||||
/>
|
||||
```
|
||||
|
||||
#### `CompactAudioPlayer`
|
||||
A minimal version for inline use:
|
||||
```tsx
|
||||
import { CompactAudioPlayer } from "@/components/AudioPlayer";
|
||||
|
||||
<CompactAudioPlayer url="https://..." />
|
||||
```
|
||||
|
||||
#### `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";
|
||||
|
||||
<AudioUpload
|
||||
postId="123"
|
||||
existingAudioUrl={post.audio_url}
|
||||
onUploadSuccess={(url, duration) => {}}
|
||||
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
|
||||
@ -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}
|
||||
</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>
|
||||
Audio
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-slate-300 line-clamp-2">
|
||||
{post.content.substring(0, 200)}...
|
||||
@ -361,6 +372,31 @@ export default function AdminPage() {
|
||||
</ReactMarkdown>
|
||||
</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
|
||||
</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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 border-t border-gray-200 dark:border-slate-800 flex justify-end gap-2">
|
||||
<button
|
||||
|
||||
262
src/app/api/audio/route.ts
Normal file
262
src/app/api/audio/route.ts
Normal file
@ -0,0 +1,262 @@
|
||||
/**
|
||||
* MP3 Upload API Endpoint
|
||||
* Handles file uploads to Supabase Storage and updates blog post metadata
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
|
||||
const serviceSupabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
);
|
||||
|
||||
const BUCKET_NAME = "podcast-audio";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function getUserFromRequest(request: Request) {
|
||||
const token = getBearerToken(request);
|
||||
if (!token) return null;
|
||||
const { data, error } = await supabase.auth.getUser(token);
|
||||
if (error) {
|
||||
console.error("Error validating auth token:", error.message);
|
||||
return null;
|
||||
}
|
||||
return data.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET audio file info for a blog post
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const postId = searchParams.get("postId");
|
||||
|
||||
if (!postId) {
|
||||
return NextResponse.json({ error: "Post ID required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("blog_messages")
|
||||
.select("id, audio_url, audio_duration")
|
||||
.eq("id", postId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching audio info:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch audio info" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
hasAudio: !!data?.audio_url,
|
||||
audioUrl: data?.audio_url,
|
||||
duration: data?.audio_duration,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Upload MP3 file and associate with blog post
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
// Verify authentication
|
||||
const user = await getUserFromRequest(request);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const postId = formData.get("postId") as string | null;
|
||||
|
||||
if (!file || !postId) {
|
||||
return NextResponse.json(
|
||||
{ error: "File and postId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith("audio/")) {
|
||||
return NextResponse.json(
|
||||
{ error: "File must be an audio file" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size (50MB limit)
|
||||
const maxSize = 50 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json(
|
||||
{ error: "File size must be less than 50MB" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get post info for filename
|
||||
const { data: post, error: postError } = await supabase
|
||||
.from("blog_messages")
|
||||
.select("date")
|
||||
.eq("id", postId)
|
||||
.single();
|
||||
|
||||
if (postError || !post) {
|
||||
return NextResponse.json({ error: "Post not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Convert file to buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Create filename
|
||||
const timestamp = Date.now();
|
||||
const sanitizedDate = post.date.replace(/[^a-zA-Z0-9-]/g, "");
|
||||
const extension = file.name.split(".").pop() || "mp3";
|
||||
const filename = `upload-${sanitizedDate}-${postId}-${timestamp}.${extension}`;
|
||||
|
||||
// Ensure bucket exists
|
||||
const { data: buckets } = await serviceSupabase.storage.listBuckets();
|
||||
const bucketExists = buckets?.some((b) => b.name === BUCKET_NAME);
|
||||
|
||||
if (!bucketExists) {
|
||||
await serviceSupabase.storage.createBucket(BUCKET_NAME, {
|
||||
public: true,
|
||||
fileSizeLimit: 50 * 1024 * 1024,
|
||||
allowedMimeTypes: ["audio/mpeg", "audio/mp3", "audio/wav", "audio/aiff"],
|
||||
});
|
||||
}
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const { data: uploadData, error: uploadError } = await serviceSupabase.storage
|
||||
.from(BUCKET_NAME)
|
||||
.upload(filename, buffer, {
|
||||
contentType: file.type,
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
console.error("Upload error:", uploadError);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to upload file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: urlData } = serviceSupabase.storage
|
||||
.from(BUCKET_NAME)
|
||||
.getPublicUrl(uploadData.path);
|
||||
|
||||
// Estimate duration (rough estimate: 1MB ≈ 1 minute at 128kbps)
|
||||
const estimatedDuration = Math.round((file.size / (128 * 1024 / 8)) * 60);
|
||||
|
||||
// Update database with audio info
|
||||
const { error: updateError } = await serviceSupabase
|
||||
.from("blog_messages")
|
||||
.update({
|
||||
audio_url: urlData.publicUrl,
|
||||
audio_duration: estimatedDuration,
|
||||
})
|
||||
.eq("id", postId);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Database update error:", updateError);
|
||||
// Try to delete the uploaded file
|
||||
await serviceSupabase.storage.from(BUCKET_NAME).remove([uploadData.path]);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update post with audio info" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: urlData.publicUrl,
|
||||
path: uploadData.path,
|
||||
size: file.size,
|
||||
estimatedDuration,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Remove audio from a blog post
|
||||
*/
|
||||
export async function DELETE(request: Request) {
|
||||
const user = await getUserFromRequest(request);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { postId } = await request.json();
|
||||
|
||||
if (!postId) {
|
||||
return NextResponse.json({ error: "Post ID required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get current audio info
|
||||
const { data: post, error: fetchError } = await supabase
|
||||
.from("blog_messages")
|
||||
.select("audio_url")
|
||||
.eq("id", postId)
|
||||
.single();
|
||||
|
||||
if (fetchError) {
|
||||
return NextResponse.json({ error: "Post not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear audio fields in database
|
||||
const { error: updateError } = await serviceSupabase
|
||||
.from("blog_messages")
|
||||
.update({
|
||||
audio_url: null,
|
||||
audio_duration: null,
|
||||
})
|
||||
.eq("id", postId);
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update post" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Delete error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { AudioPlayer } from "@/components/AudioPlayer";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@ -224,9 +225,9 @@ function BlogPageContent() {
|
||||
|
||||
{/* Audio Player */}
|
||||
{selectedPost.audio_url && (
|
||||
<div className="mb-8 p-4 bg-gray-50 rounded-xl border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-slate-300">🎧 Listen to this episode</span>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-slate-300">🎧 Listen to this post</span>
|
||||
<Link
|
||||
href="/podcast"
|
||||
className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
@ -234,13 +235,10 @@ function BlogPageContent() {
|
||||
View all episodes →
|
||||
</Link>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
className="w-full"
|
||||
src={selectedPost.audio_url}
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<AudioPlayer
|
||||
url={selectedPost.audio_url}
|
||||
duration={selectedPost.audio_duration || undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
219
src/components/AudioPlayer.tsx
Normal file
219
src/components/AudioPlayer.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
interface AudioPlayerProps {
|
||||
url: string;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AudioPlayer({ url, duration, className = "" }: AudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [audioDuration, setAudioDuration] = useState(duration || 0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
|
||||
const handleLoadedMetadata = () => {
|
||||
setAudioDuration(audio.duration);
|
||||
setIsLoaded(true);
|
||||
};
|
||||
const handleEnded = () => setIsPlaying(false);
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
|
||||
audio.addEventListener("timeupdate", handleTimeUpdate);
|
||||
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
audio.addEventListener("ended", handleEnded);
|
||||
audio.addEventListener("play", handlePlay);
|
||||
audio.addEventListener("pause", handlePause);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
audio.removeEventListener("ended", handleEnded);
|
||||
audio.removeEventListener("play", handlePlay);
|
||||
audio.removeEventListener("pause", handlePause);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePlay = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const newTime = parseFloat(e.target.value);
|
||||
audio.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
audio.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (isNaN(time)) return "0:00";
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const progress = audioDuration ? (currentTime / audioDuration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-50 rounded-xl p-4 border border-gray-200 dark:bg-slate-900 dark:border-slate-800 ${className}`}>
|
||||
<audio ref={audioRef} src={url} preload="metadata" />
|
||||
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-10 h-10 flex items-center justify-center bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400 mb-1">
|
||||
<span className="font-medium">{formatTime(currentTime)}</span>
|
||||
<span>/</span>
|
||||
<span>{formatTime(audioDuration)}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative h-2 bg-gray-200 rounded-full overflow-hidden dark:bg-slate-700">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={audioDuration || 100}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
aria-label="Seek"
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-blue-600 rounded-full transition-all duration-100"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.volume = volume > 0 ? 0 : 1;
|
||||
setVolume(volume > 0 ? 0 : 1);
|
||||
}
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||
aria-label={volume > 0 ? "Mute" : "Unmute"}
|
||||
>
|
||||
{volume > 0 ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-20 h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-slate-700"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CompactAudioPlayerProps {
|
||||
url: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CompactAudioPlayer({ url, className = "" }: CompactAudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(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 (
|
||||
<div className={`inline-flex items-center gap-2 ${className}`}>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={url}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
/>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-8 h-8 flex items-center justify-center bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-slate-400">
|
||||
{isPlaying ? "Playing..." : "Listen"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
src/components/AudioUpload.tsx
Normal file
250
src/components/AudioUpload.tsx
Normal file
@ -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<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Current Audio Status */}
|
||||
{existingAudioUrl && (
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200 dark:bg-blue-900/20 dark:border-blue-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" 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>
|
||||
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
Audio attached
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-sm text-red-600 hover:text-red-800 disabled:opacity-50 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
{isDeleting ? "Removing..." : "Remove"}
|
||||
</button>
|
||||
</div>
|
||||
<audio
|
||||
controls
|
||||
className="w-full mt-3"
|
||||
src={existingAudioUrl}
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Section */}
|
||||
{!existingAudioUrl && (
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors dark:border-slate-700 dark:hover:border-blue-500">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileSelect}
|
||||
disabled={isUploading}
|
||||
className="hidden"
|
||||
id={`audio-upload-${postId}`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`audio-upload-${postId}`}
|
||||
className="cursor-pointer block"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="w-10 h-10 text-gray-400 mb-3 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-slate-300">
|
||||
{isUploading ? "Uploading..." : "Click to upload MP3 or audio file"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 mt-1 dark:text-slate-400">
|
||||
Max file size: 50MB
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{isUploading && uploadProgress > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden dark:bg-slate-700">
|
||||
<div
|
||||
className="h-full bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 mt-1 dark:text-slate-400">
|
||||
{uploadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700 dark:bg-green-900/20 dark:border-green-800 dark:text-green-300">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
supabase/migrations/20250225_add_audio_columns.sql
Normal file
24
supabase/migrations/20250225_add_audio_columns.sql
Normal file
@ -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)';
|
||||
Loading…
Reference in New Issue
Block a user