feat: some frontend and backend chnages

This commit is contained in:
Mann Patel
2026-02-15 07:59:54 -07:00
parent 8272b214e6
commit 341d51b17a
13 changed files with 980 additions and 79 deletions

View File

@@ -13,12 +13,6 @@ import zipfile
from flask import send_file
from typing import Dict, Any
import requests
from dotenv import load_dotenv
from faster_whisper import WhisperModel
from flask import Blueprint, jsonify, request
@@ -48,6 +42,9 @@ from db_queries import (
upload_storage_object,
upsert_archive_metadata,
upsert_archive_rights,
delete_rag_chunks, delete_archive_files, delete_metadata,
delete_rights, delete_audio_post, update_audio_post,
get_audio_post_by_id
)
load_dotenv()
@@ -723,3 +720,205 @@ def download_post(post_id: int):
import traceback
traceback.print_exc()
return _error(f"Failed to create download: {str(e)}", 500)
# @api.delete("/posts/<int:post_id>")
# def api_delete_post(post_id: int):
# """
# Permanently delete a post and all associated data.
# Only the post owner can delete their posts.
# """
# user_id = request.args.get("user_id", type=int)
#
# if not user_id:
# return _error("'user_id' is required for authorization.", 400)
#
# # Get the post
# post = get_audio_post_by_id(post_id)
# if not post:
# return _error("Post not found.", 404)
#
# # Check ownership
# if post.get("user_id") != user_id:
# return _error("You don't have permission to delete this post.", 403)
#
# try:
# # Delete associated data in order
# # 1. Delete RAG chunks
# supabase.table("rag_chunks").delete().eq("post_id", post_id).execute()
#
# # 2. Delete archive files (and from storage if needed)
# files = list_archive_files(post_id)
# for file_info in files:
# # Optionally delete from Supabase storage
# try:
# bucket, object_path = _parse_bucket_path(file_info["path"])
# supabase.storage.from_(bucket).remove([object_path])
# except:
# pass # Continue even if storage delete fails
#
# supabase.table("archive_files").delete().eq("post_id", post_id).execute()
#
# # 3. Delete metadata
# supabase.table("archive_metadata").delete().eq("post_id", post_id).execute()
#
# # 4. Delete rights
# supabase.table("archive_rights").delete().eq("post_id", post_id).execute()
#
# # 5. Delete the post itself
# supabase.table("audio_posts").delete().eq("post_id", post_id).execute()
#
# # Log the deletion
# add_audit_log({
# "post_id": post_id,
# "user_id": user_id,
# "action": "post.deleted",
# "details": json.dumps({"title": post.get("title")})
# })
#
# return jsonify({"message": "Post deleted successfully", "post_id": post_id})
#
# except Exception as e:
# return _error(f"Failed to delete post: {str(e)}", 500)
#
#
# # ==================== UPDATE POST (Edit) ====================
#
# @api.put("/posts/<int:post_id>/edit")
# def api_edit_post(post_id: int):
# """
# Update post title, description, and visibility.
# Only the post owner can edit their posts.
# """
# payload = request.get_json(force=True, silent=False) or {}
# user_id = payload.get("user_id")
#
# if not user_id:
# return _error("'user_id' is required for authorization.", 400)
#
# # Get the post
# post = get_audio_post_by_id(post_id)
# if not post:
# return _error("Post not found.", 404)
#
# # Check ownership
# if post.get("user_id") != user_id:
# return _error("You don't have permission to edit this post.", 403)
#
# # Prepare updates
# updates = {}
#
# if "title" in payload:
# title = (payload["title"] or "").strip()
# if not title:
# return _error("Title cannot be empty.", 400)
# updates["title"] = title
#
# if "description" in payload:
# updates["description"] = payload["description"]
#
# if "visibility" in payload:
# visibility = (payload["visibility"] or "").strip().lower()
# if visibility not in {"private", "public"}:
# return _error("'visibility' must be 'private' or 'public'.", 400)
# updates["visibility"] = visibility
#
# if not updates:
# return _error("No valid fields to update.", 400)
#
# try:
# updated_post = update_audio_post(post_id, updates)
#
# # Log the edit
# add_audit_log({
# "post_id": post_id,
# "user_id": user_id,
# "action": "post.edited",
# "details": json.dumps({"changes": list(updates.keys())})
# })
#
# return jsonify(updated_post)
#
# except Exception as e:
# return _error(f"Failed to update post: {str(e)}", 500)
#
#
# # ==================== Helper function for _parse_bucket_path ====================
#
# def _parse_bucket_path(stored_path: str) -> tuple:
# """
# Parse stored path like 'archives/user/uuid/file.mp4'
# Returns: ('archives', 'user/uuid/file.mp4')
# """
# parts = (stored_path or "").split("/", 1)
# if len(parts) != 2:
# raise ValueError(f"Invalid storage path: {stored_path}")
# return parts[0], parts[1]
@api.delete("/posts/<int:post_id>")
def api_delete_post(post_id: int):
user_id = request.args.get("user_id", type=int)
if not user_id:
return _error("'user_id' is required for authorization.", 400)
post = get_audio_post_by_id(post_id)
if not post:
return _error("Post not found.", 404)
if post.get("user_id") != user_id:
return _error("You don't have permission to delete this post.", 403)
try:
delete_rag_chunks(post_id)
delete_archive_files(post_id)
delete_metadata(post_id)
delete_rights(post_id)
delete_audio_post(post_id)
# ❌ Skip audit log for now
return jsonify({"message": "Post and all related data deleted successfully", "post_id": post_id})
except Exception as e:
return _error(f"Failed to delete post: {str(e)}", 500)
@api.put("/posts/<int:post_id>/edit")
def api_edit_post(post_id: int):
payload = request.get_json(force=True) or {}
user_id = payload.get("user_id")
if not user_id:
return _error("'user_id' is required for authorization.", 400)
post = get_audio_post_by_id(post_id)
if not post:
return _error("Post not found.", 404)
if post.get("user_id") != user_id:
return _error("You don't have permission to edit this post.", 403)
updates = {}
if "title" in payload:
title = (payload["title"] or "").strip()
if not title:
return _error("Title cannot be empty.", 400)
updates["title"] = title
if "description" in payload:
updates["description"] = payload["description"]
if "visibility" in payload:
visibility = (payload["visibility"] or "").strip().lower()
if visibility not in {"private", "public"}:
return _error("'visibility' must be 'private' or 'public'.", 400)
updates["visibility"] = visibility
if not updates:
return _error("No valid fields to update.", 400)
try:
updated_post = update_audio_post(post_id, updates)
add_audit_log({
"post_id": post_id,
"user_id": user_id,
"action": "post.edited",
"details": json.dumps({"changes": list(updates.keys())})
})
return jsonify(updated_post)
except Exception as e:
return _error(f"Failed to update post: {str(e)}", 500)

View File

@@ -458,3 +458,34 @@ def get_post_bundle(post_id: int) -> Dict[str, Any]:
"rag_chunks": list_rag_chunks(post_id, page=1, limit=1000),
"audit_log": list_audit_logs(post_id=post_id, page=1, limit=200),
}
def delete_rag_chunks(post_id: int):
supabase.table("rag_chunks").delete().eq("post_id", post_id).execute()
def delete_archive_files(post_id: int):
files = list_archive_files(post_id)
for file_info in files:
try:
bucket, object_path = _parse_bucket_path(file_info["path"])
supabase.storage.from_(bucket).remove([object_path])
except:
pass
supabase.table("archive_files").delete().eq("post_id", post_id).execute()
def delete_metadata(post_id: int):
supabase.table("archive_metadata").delete().eq("post_id", post_id).execute()
def delete_rights(post_id: int):
supabase.table("archive_rights").delete().eq("post_id", post_id).execute()
def delete_audio_post(post_id: int):
supabase.table("audio_posts").delete().eq("post_id", post_id).execute()
def update_audio_post(post_id: int, updates: dict):
supabase.table("audio_posts").update(updates).eq("post_id", post_id).execute()
return get_audio_post_by_id(post_id)
def get_audio_post_by_id(post_id: int):
result = supabase.table("audio_posts").select("*").eq("post_id", post_id).single().execute()
return result.data if result.data else None

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- <link href="/src/index.css" rel="stylesheet"> -->
<title>frontend</title>
<title>VoiceVault</title>
</head>
<body>
<div id="root"></div>

View File

@@ -6,6 +6,7 @@ import CreatePost from './pages/CreatePost'
import History from './pages/History'
import Settings from './pages/Settings'
import Search from './pages/Search'
import PostDetail from './pages/PostDetail'
import { api } from './api'
export default function App() {
@@ -18,6 +19,7 @@ export default function App() {
const [loginError, setLoginError] = useState(null)
const [searchQuery, setSearchQuery] = useState('')
const [headerSearchQuery, setHeaderSearchQuery] = useState('')
const [viewingPostId, setViewingPostId] = useState(null)
// Check for saved user on mount
useEffect(() => {
@@ -78,6 +80,15 @@ export default function App() {
const handleNavigateToSearch = (query) => {
setActiveTab('search')
setHeaderSearchQuery(query)
setViewingPostId(null) // Clear any post detail view
}
const handleViewPost = (postId) => {
setViewingPostId(postId)
}
const handleBackFromPost = () => {
setViewingPostId(null)
}
const handlePostCreated = () => {
@@ -94,17 +105,22 @@ export default function App() {
const renderPage = () => {
if (!user) return null
// If viewing a specific post, show PostDetail
if (viewingPostId) {
return <PostDetail postId={viewingPostId} user={user} onBack={handleBackFromPost} />
}
switch (activeTab) {
case 'create':
return <CreatePost user={user} onPostCreated={handlePostCreated} />
case 'search':
return <Search user={user} initialQuery={headerSearchQuery} />
return <Search user={user} initialQuery={headerSearchQuery} onViewPost={handleViewPost} />
case 'history':
return <History user={user} />
return <History user={user} onViewPost={handleViewPost} />
case 'settings':
return <Settings user={user} onUpdate={handleUserUpdate} />
default:
return <Feed user={user} />
return <Feed user={user} onViewPost={handleViewPost} />
}
}

View File

@@ -128,9 +128,20 @@ class ApiClient {
});
}
async deletePost(postId) {
// Update status to mark as deleted
return this.updatePost(postId, { status: 'deleted' });
async deletePost(postId, userId) {
// Proper DELETE request with user authorization
return this.request(`/posts/${postId}?user_id=${userId}`, {
method: 'DELETE',
});
}
async editPost(postId, updates) {
// Updates can include: title, description, visibility
// Must include user_id for authorization
return this.request(`/posts/${postId}/edit`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
async getPostMetadata(postId) {

View File

@@ -1,8 +1,8 @@
import { Play, Pause, Volume2, MoreVertical, Clock, ChevronDown, ChevronUp, Download } from 'lucide-react'
import { Play, Pause, Volume2, Clock, ChevronDown, ChevronUp, Download, ExternalLink } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
import { api } from '../api'
export default function AudioPostCard({ post }) {
export default function AudioPostCard({ post, onViewPost }) {
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
@@ -151,6 +151,13 @@ export default function AudioPostCard({ post }) {
setCurrentTime(0)
}
const handleView = (postId) => {
if (onViewPost) {
onViewPost(post.postId)
}
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
@@ -186,11 +193,15 @@ export default function AudioPostCard({ post }) {
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{post.status === 'ready' && (
<button
onClick={handleDownload}
disabled={downloading}
className="flex items-center gap-2 px-3 py-1.5 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Download post as ZIP"
>
{downloading ? (
@@ -206,10 +217,18 @@ export default function AudioPostCard({ post }) {
)}
</button>
)}
{post.status === 'ready' && (
<button
onClick={handleView}
className="flex items-center gap-1 text-sm text-[#f4b840] hover:text-[#e5a930]"
>
<span>View Post</span>
<ExternalLink size={14} />
</button>
)}
</div>
</div>
{/* Description */}
{post.description && (
@@ -218,7 +237,6 @@ export default function AudioPostCard({ post }) {
</p>
)}
{/* Audio Player - Only show if ready */}
{post.status === 'ready' && (
<>

View File

@@ -0,0 +1,156 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { api } from '../api'
export default function EditPostModal({ post, user, onClose, onSave }) {
const [title, setTitle] = useState(post.title)
const [description, setDescription] = useState(post.description || '')
const [visibility, setVisibility] = useState(post.visibility)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const handleSubmit = async (e) => {
e.preventDefault()
if (!title.trim()) {
setError('Title is required')
return
}
setSaving(true)
setError(null)
try {
const updates = {
user_id: user.user_id,
title: title.trim(),
description: description.trim(),
visibility
}
await api.editPost(post.post_id, updates)
onSave?.()
onClose()
} catch (err) {
setError(err.message || 'Failed to update post')
} finally {
setSaving(false)
}
}
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={onClose}
>
<div
className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">Edit Post</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
>
<X size={24} />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-800">
{error}
</div>
)}
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
Title *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
required
disabled={saving}
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent resize-none"
disabled={saving}
/>
</div>
{/* Visibility */}
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
Visibility
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setVisibility('private')}
className={`flex-1 px-4 py-2 rounded text-sm font-medium transition-colors ${
visibility === 'private'
? 'bg-[#f4b840] text-[#1a1a1a]'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
disabled={saving}
>
Private
</button>
<button
type="button"
onClick={() => setVisibility('public')}
className={`flex-1 px-4 py-2 rounded text-sm font-medium transition-colors ${
visibility === 'public'
? 'bg-[#f4b840] text-[#1a1a1a]'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
disabled={saving}
>
Public
</button>
</div>
<p className="text-xs text-gray-600 mt-1">
{visibility === 'private' ? 'Only you can see this post' : 'Anyone can see this post'}
</p>
</div>
{/* Footer */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
disabled={saving}
>
Cancel
</button>
<button
type="submit"
disabled={saving || !title.trim()}
className="flex-1 px-4 py-2 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -32,7 +32,7 @@ export default function Header({ onSearch, onLogout, onNavigateToSearch }) {
</div>
{/* Center: Search Bar */}
<form onSubmit={handleSearch} className="flex-1 max-w-2xl">
{/* <form onSubmit={handleSearch} className="flex-1 max-w-2xl">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
<input
@@ -44,7 +44,7 @@ export default function Header({ onSearch, onLogout, onNavigateToSearch }) {
className="w-full bg-gray-50 border border-gray-300 rounded-lg pl-10 pr-4 py-2 text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#f4b840] focus:border-transparent"
/>
</div>
</form>
</form> */}
{/* Right: Logout */}
<div className="flex items-center gap-3 flex-shrink-0">

View File

@@ -1,12 +1,15 @@
import { useState, useEffect } from 'react'
import { FileText, Trash2, Eye } from 'lucide-react'
import { FileText, Trash2, Eye, Edit2 } from 'lucide-react'
import { api } from '../api'
import EditPostModal from '../components/EditPostModal'
export default function History({ user }) {
export default function History({ user, onViewPost }) {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [page, setPage] = useState(1)
const [deleting, setDeleting] = useState(null)
const [editingPost, setEditingPost] = useState(null)
useEffect(() => {
if (user?.user_id) {
@@ -29,29 +32,37 @@ export default function History({ user }) {
}
const handleDelete = async (postId) => {
if (!confirm('Are you sure you want to delete this post?')) {
if (!confirm('Are you sure you want to delete this post? This action cannot be undone.')) {
return
}
setDeleting(postId)
try {
await api.deletePost(postId)
// Refresh the list
fetchHistory()
await api.deletePost(postId, user.user_id)
// Remove from local state immediately
setPosts(posts.filter(p => p.post_id !== postId))
} catch (err) {
alert('Failed to delete post: ' + err.message)
} finally {
setDeleting(null)
}
}
const handleView = async (postId) => {
try {
const bundle = await api.getPostBundle(postId)
console.log('Post bundle:', bundle)
// You could open a modal or navigate to a detail view
} catch (err) {
alert('Failed to load post details: ' + err.message)
const handleView = (postId) => {
if (onViewPost) {
onViewPost(postId)
}
}
const handleEdit = (post) => {
setEditingPost(post)
}
const handleSaveEdit = () => {
// Refresh the list after edit
fetchHistory()
}
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
@@ -106,7 +117,12 @@ export default function History({ user }) {
{posts.length > 0 ? (
<div className="space-y-3">
{posts.map((post) => (
<div key={post.post_id} className="flex items-start gap-4 p-4 hover:bg-gray-50 rounded-lg border border-gray-200 transition-colors">
<div
key={post.post_id}
className={`flex items-start gap-4 p-4 hover:bg-gray-50 rounded-lg border border-gray-200 transition-colors ${
deleting === post.post_id ? 'opacity-50 pointer-events-none' : ''
}`}
>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 mb-1">{post.title}</h4>
@@ -144,19 +160,33 @@ export default function History({ user }) {
</div>
<div className="flex gap-2 flex-shrink-0">
{post.status === 'ready' && (
<button
onClick={() => handleView(post.post_id)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="View details"
title="View full post"
>
<Eye size={18} />
</button>
)}
<button
onClick={() => handleEdit(post)}
className="p-2 text-green-600 hover:bg-green-50 rounded transition-colors"
title="Edit post"
>
<Edit2 size={18} />
</button>
<button
onClick={() => handleDelete(post.post_id)}
className="p-2 text-red-500 hover:bg-red-50 rounded transition-colors"
disabled={deleting === post.post_id}
className="p-2 text-red-500 hover:bg-red-50 rounded transition-colors disabled:opacity-50"
title="Delete post"
>
{deleting === post.post_id ? (
<div className="animate-spin rounded-full h-[18px] w-[18px] border-b-2 border-red-500"></div>
) : (
<Trash2 size={18} />
)}
</button>
</div>
</div>
@@ -170,7 +200,7 @@ export default function History({ user }) {
</div>
{/* Summary Stats */}
{posts.length > 0 && (
{/* {posts.length > 0 && (
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-gray-900">{posts.length}</div>
@@ -189,7 +219,7 @@ export default function History({ user }) {
<div className="text-sm text-gray-600">Public</div>
</div>
</div>
)}
)} */}
{/* Pagination */}
{posts.length >= 20 && (
@@ -210,6 +240,16 @@ export default function History({ user }) {
</button>
</div>
)}
{/* Edit Modal */}
{editingPost && (
<EditPostModal
post={editingPost}
user={user}
onClose={() => setEditingPost(null)}
onSave={handleSaveEdit}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,424 @@
import { useState, useEffect, useRef } from 'react'
import { ArrowLeft, Play, Pause, Volume2, Clock, Download, Share2, Calendar, Globe, Lock } from 'lucide-react'
import { api } from '../api'
export default function PostDetail({ postId, user, onBack }) {
const [post, setPost] = useState(null)
const [metadata, setMetadata] = useState(null)
const [chunks, setChunks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// Audio player state
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [volume, setVolume] = useState(1)
const [downloading, setDownloading] = useState(false)
const audioRef = useRef(null)
useEffect(() => {
if (postId) {
loadPostData()
}
}, [postId])
const loadPostData = async () => {
setLoading(true)
setError(null)
try {
// Load post data
const postData = await api.getPost(postId)
setPost(postData)
// Load audio URL if available
if (postData.audio_url && audioRef.current) {
audioRef.current.src = postData.audio_url
audioRef.current.load()
}
// Load metadata (contains transcript)
try {
const metadataResponse = await api.getPostMetadata(postId)
if (metadataResponse && metadataResponse.metadata) {
const metadataObj = JSON.parse(metadataResponse.metadata)
setMetadata(metadataObj)
}
} catch (err) {
console.error('Failed to load metadata:', err)
}
// Load RAG chunks (timestamped transcript segments)
try {
const chunksResponse = await api.request(`/posts/${postId}/chunks?limit=1000`)
setChunks(chunksResponse.chunks || [])
} catch (err) {
console.error('Failed to load chunks:', err)
}
} catch (err) {
setError(err.message || 'Failed to load post')
} finally {
setLoading(false)
}
}
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const formatTime = (seconds) => {
if (!seconds || isNaN(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
const extractTranscript = () => {
if (!metadata?.prompt) return null
const match = metadata.prompt.match(/Transcript:\n([\s\S]*?)\n\nAnswer user questions/)
return match ? match[1].trim() : null
}
// Audio player handlers
const togglePlay = () => {
if (!audioRef.current) return
if (isPlaying) {
audioRef.current.pause()
} else {
audioRef.current.play()
}
setIsPlaying(!isPlaying)
}
const handleTimeUpdate = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime)
}
}
const handleLoadedMetadata = () => {
if (audioRef.current) {
setDuration(audioRef.current.duration)
}
}
const handleSeek = (e) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const percentage = x / rect.width
const newTime = percentage * duration
if (audioRef.current) {
audioRef.current.currentTime = newTime
setCurrentTime(newTime)
}
}
const handleVolumeChange = (e) => {
const newVolume = parseFloat(e.target.value)
setVolume(newVolume)
if (audioRef.current) {
audioRef.current.volume = newVolume
}
}
const handleEnded = () => {
setIsPlaying(false)
setCurrentTime(0)
}
const handleDownload = async () => {
if (downloading) return
setDownloading(true)
try {
const zipBlob = await api.exportPost(postId)
const url = window.URL.createObjectURL(zipBlob)
const a = document.createElement("a")
a.href = url
a.download = `${post.title.replace(/[^a-zA-Z0-9]/g, "_")}_${postId}.zip`
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
} catch (err) {
console.error("Failed to download:", err)
alert(`Download failed: ${err.message}`)
} finally {
setDownloading(false)
}
}
const handleShare = () => {
if (navigator.share) {
navigator.share({
title: post.title,
text: post.description,
url: window.location.href
})
} else {
// Copy link to clipboard
navigator.clipboard.writeText(window.location.href)
alert('Link copied to clipboard!')
}
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
const transcript = extractTranscript()
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#f4b840]"></div>
</div>
)
}
if (error || !post) {
return (
<div className="max-w-4xl mx-auto py-8">
<button onClick={onBack} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4">
<ArrowLeft size={20} />
<span>Back</span>
</button>
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p className="text-red-800">{error || 'Post not found'}</p>
</div>
</div>
)
}
return (
<div className="max-w-4xl mx-auto py-8 space-y-6">
{/* Back Button */}
<button
onClick={onBack}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
>
<ArrowLeft size={20} />
<span>Back</span>
</button>
{/* Header Card */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
{/* Title and Meta */}
<div className="mb-6">
<div className="flex items-start justify-between mb-4">
<h1 className="text-3xl font-bold text-gray-900 flex-1">{post.title}</h1>
<div className="flex items-center gap-2">
{post.visibility === 'private' ? (
<Lock size={20} className="text-gray-500" />
) : (
<Globe size={20} className="text-green-500" />
)}
</div>
</div>
{/* Metadata */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600">
<div className="flex items-center gap-1">
<Calendar size={16} />
<span>{formatDate(post.created_at)}</span>
</div>
<span></span>
<div className={`px-2 py-1 rounded text-xs ${
post.status === 'ready' ? 'bg-green-100 text-green-700' :
post.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{post.status}
</div>
{post.language && (
<>
<span></span>
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
{post.language.toUpperCase()}
</span>
</>
)}
</div>
</div>
{/* Description */}
{post.description && (
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-gray-700 leading-relaxed">{post.description}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<button
onClick={handleDownload}
disabled={downloading || post.status !== 'ready'}
className="flex items-center gap-2 px-4 py-2 bg-[#f4b840] hover:bg-[#e5a930] text-[#1a1a1a] rounded font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{downloading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#1a1a1a]"></div>
<span>Downloading...</span>
</>
) : (
<>
<Download size={18} />
<span>Download</span>
</>
)}
</button>
<button
onClick={handleShare}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 hover:bg-gray-50 text-gray-700 rounded font-medium transition-colors"
>
<Share2 size={18} />
<span>Share</span>
</button>
</div>
</div>
{/* Audio Player */}
{post.status === 'ready' && (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Audio Player</h2>
<audio
ref={audioRef}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
preload="metadata"
/>
<div className="flex items-center gap-4 mb-4">
<button
onClick={togglePlay}
className="w-12 h-12 bg-[#f4b840] hover:bg-[#e5a930] rounded-full flex items-center justify-center text-[#1a1a1a] transition-colors"
>
{isPlaying ? (
<Pause size={20} fill="currentColor" />
) : (
<Play size={20} fill="currentColor" />
)}
</button>
<div className="flex-1">
<div
className="h-2 bg-gray-300 rounded-full overflow-hidden mb-2 cursor-pointer"
onClick={handleSeek}
>
<div
className="h-full bg-[#f4b840] rounded-full transition-all"
style={{ width: `${progress}%` }}
></div>
</div>
<div className="flex justify-between text-xs text-gray-600">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Volume2 size={18} className="text-gray-600" />
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={handleVolumeChange}
className="w-20 h-2 bg-gray-300 rounded-full appearance-none cursor-pointer"
/>
</div>
</div>
</div>
)}
{/* Full Transcript */}
{transcript && (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Full Transcript</h2>
<div className="prose max-w-none">
<p className="text-gray-700 leading-relaxed whitespace-pre-wrap">{transcript}</p>
</div>
{metadata?.transcript_length_chars && (
<p className="text-xs text-gray-500 mt-4">
{metadata.transcript_length_chars.toLocaleString()} characters
</p>
)}
</div>
)}
{/* Timestamped Segments */}
{chunks.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Timestamped Segments ({chunks.length})
</h2>
<div className="space-y-3 max-h-96 overflow-y-auto">
{chunks.map((chunk, index) => (
<div
key={chunk.chunk_id || index}
className="p-4 bg-gray-50 rounded border border-gray-200 hover:bg-gray-100 transition-colors"
>
<div className="flex items-start gap-3">
<div className="flex items-center gap-2 text-sm font-medium text-[#f4b840] flex-shrink-0">
<Clock size={14} />
<span>{formatTime(chunk.start_sec)}</span>
</div>
<p className="text-sm text-gray-700 flex-1">{chunk.text}</p>
</div>
{chunk.confidence && (
<div className="mt-2 text-xs text-gray-500">
Confidence: {Math.round(chunk.confidence * 100)}%
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Metadata */}
{metadata && (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Metadata</h2>
<dl className="grid grid-cols-2 gap-4 text-sm">
{metadata.source_file && (
<>
<dt className="text-gray-600">Source File:</dt>
<dd className="text-gray-900 font-medium">{metadata.source_file}</dd>
</>
)}
{metadata.language && (
<>
<dt className="text-gray-600">Language:</dt>
<dd className="text-gray-900 font-medium">{metadata.language.toUpperCase()}</dd>
</>
)}
{metadata.transcript_length_chars && (
<>
<dt className="text-gray-600">Transcript Length:</dt>
<dd className="text-gray-900 font-medium">
{metadata.transcript_length_chars.toLocaleString()} characters
</dd>
</>
)}
{chunks.length > 0 && (
<>
<dt className="text-gray-600">Segments:</dt>
<dd className="text-gray-900 font-medium">{chunks.length} chunks</dd>
</>
)}
</dl>
</div>
)}
</div>
)
}

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { Search as SearchIcon, Sparkles, Clock, ExternalLink } from 'lucide-react'
import { api } from '../api'
export default function Search({ user, initialQuery = '' }) {
export default function Search({ user, initialQuery = '', onViewPost}) {
const [query, setQuery] = useState(initialQuery)
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
@@ -48,6 +48,12 @@ export default function Search({ user, initialQuery = '' }) {
performSearch(query)
}
const handleView = (postId) => {
if (onViewPost) {
onViewPost(postId)
}
}
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
@@ -173,13 +179,13 @@ export default function Search({ user, initialQuery = '' }) {
)}
</div>
</div>
<a
href={`#post-${result.post_id}`}
<button
onClick={() => onViewPost && onViewPost(result.post_id)}
className="flex items-center gap-1 text-sm text-[#f4b840] hover:text-[#e5a930]"
>
<span>View Post</span>
<ExternalLink size={14} />
</a>
</button>
</div>
{/* Transcript Text with Highlighting */}