feat: some frontend and backend chnages
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Handles all communication with Flask API
|
||||
*/
|
||||
|
||||
const API_BASE_URL= 'http://localhost:5000/api';
|
||||
const API_BASE_URL = 'http://localhost:5000/api';
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' && (
|
||||
<>
|
||||
|
||||
156
frontend/src/components/EditPostModal.jsx
Normal file
156
frontend/src/components/EditPostModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
424
frontend/src/pages/PostDetail.jsx
Normal file
424
frontend/src/pages/PostDetail.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user