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 flask import send_file
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from faster_whisper import WhisperModel
|
from faster_whisper import WhisperModel
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
@@ -48,6 +42,9 @@ from db_queries import (
|
|||||||
upload_storage_object,
|
upload_storage_object,
|
||||||
upsert_archive_metadata,
|
upsert_archive_metadata,
|
||||||
upsert_archive_rights,
|
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()
|
load_dotenv()
|
||||||
@@ -723,3 +720,205 @@ def download_post(post_id: int):
|
|||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return _error(f"Failed to create download: {str(e)}", 500)
|
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),
|
"rag_chunks": list_rag_chunks(post_id, page=1, limit=1000),
|
||||||
"audit_log": list_audit_logs(post_id=post_id, page=1, limit=200),
|
"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" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!-- <link href="/src/index.css" rel="stylesheet"> -->
|
<!-- <link href="/src/index.css" rel="stylesheet"> -->
|
||||||
<title>frontend</title>
|
<title>VoiceVault</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import CreatePost from './pages/CreatePost'
|
|||||||
import History from './pages/History'
|
import History from './pages/History'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
import Search from './pages/Search'
|
import Search from './pages/Search'
|
||||||
|
import PostDetail from './pages/PostDetail'
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -18,6 +19,7 @@ export default function App() {
|
|||||||
const [loginError, setLoginError] = useState(null)
|
const [loginError, setLoginError] = useState(null)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [headerSearchQuery, setHeaderSearchQuery] = useState('')
|
const [headerSearchQuery, setHeaderSearchQuery] = useState('')
|
||||||
|
const [viewingPostId, setViewingPostId] = useState(null)
|
||||||
|
|
||||||
// Check for saved user on mount
|
// Check for saved user on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,6 +80,15 @@ export default function App() {
|
|||||||
const handleNavigateToSearch = (query) => {
|
const handleNavigateToSearch = (query) => {
|
||||||
setActiveTab('search')
|
setActiveTab('search')
|
||||||
setHeaderSearchQuery(query)
|
setHeaderSearchQuery(query)
|
||||||
|
setViewingPostId(null) // Clear any post detail view
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewPost = (postId) => {
|
||||||
|
setViewingPostId(postId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackFromPost = () => {
|
||||||
|
setViewingPostId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePostCreated = () => {
|
const handlePostCreated = () => {
|
||||||
@@ -94,17 +105,22 @@ export default function App() {
|
|||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
|
// If viewing a specific post, show PostDetail
|
||||||
|
if (viewingPostId) {
|
||||||
|
return <PostDetail postId={viewingPostId} user={user} onBack={handleBackFromPost} />
|
||||||
|
}
|
||||||
|
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'create':
|
case 'create':
|
||||||
return <CreatePost user={user} onPostCreated={handlePostCreated} />
|
return <CreatePost user={user} onPostCreated={handlePostCreated} />
|
||||||
case 'search':
|
case 'search':
|
||||||
return <Search user={user} initialQuery={headerSearchQuery} />
|
return <Search user={user} initialQuery={headerSearchQuery} onViewPost={handleViewPost} />
|
||||||
case 'history':
|
case 'history':
|
||||||
return <History user={user} />
|
return <History user={user} onViewPost={handleViewPost} />
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <Settings user={user} onUpdate={handleUserUpdate} />
|
return <Settings user={user} onUpdate={handleUserUpdate} />
|
||||||
default:
|
default:
|
||||||
return <Feed user={user} />
|
return <Feed user={user} onViewPost={handleViewPost} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Handles all communication with Flask API
|
* Handles all communication with Flask API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_BASE_URL= 'http://localhost:5000/api';
|
const API_BASE_URL = 'http://localhost:5000/api';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -128,9 +128,20 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deletePost(postId) {
|
async deletePost(postId, userId) {
|
||||||
// Update status to mark as deleted
|
// Proper DELETE request with user authorization
|
||||||
return this.updatePost(postId, { status: 'deleted' });
|
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) {
|
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 { useState, useRef, useEffect } from 'react'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
|
||||||
export default function AudioPostCard({ post }) {
|
export default function AudioPostCard({ post, onViewPost }) {
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
const [duration, setDuration] = useState(0)
|
const [duration, setDuration] = useState(0)
|
||||||
@@ -151,6 +151,13 @@ export default function AudioPostCard({ post }) {
|
|||||||
setCurrentTime(0)
|
setCurrentTime(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleView = (postId) => {
|
||||||
|
if (onViewPost) {
|
||||||
|
onViewPost(post.postId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -186,11 +193,15 @@ export default function AudioPostCard({ post }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
|
||||||
{post.status === 'ready' && (
|
{post.status === 'ready' && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={downloading}
|
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"
|
title="Download post as ZIP"
|
||||||
>
|
>
|
||||||
{downloading ? (
|
{downloading ? (
|
||||||
@@ -206,10 +217,18 @@ export default function AudioPostCard({ post }) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{post.description && (
|
{post.description && (
|
||||||
@@ -218,7 +237,6 @@ export default function AudioPostCard({ post }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Audio Player - Only show if ready */}
|
{/* Audio Player - Only show if ready */}
|
||||||
{post.status === '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>
|
</div>
|
||||||
|
|
||||||
{/* Center: Search Bar */}
|
{/* Center: Search Bar */}
|
||||||
<form onSubmit={handleSearch} className="flex-1 max-w-2xl">
|
{/* <form onSubmit={handleSearch} className="flex-1 max-w-2xl">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
|
||||||
<input
|
<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"
|
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>
|
</div>
|
||||||
</form>
|
</form> */}
|
||||||
|
|
||||||
{/* Right: Logout */}
|
{/* Right: Logout */}
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { api } from '../api'
|
||||||
|
import EditPostModal from '../components/EditPostModal'
|
||||||
|
|
||||||
export default function History({ user }) {
|
export default function History({ user, onViewPost }) {
|
||||||
const [posts, setPosts] = useState([])
|
const [posts, setPosts] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [deleting, setDeleting] = useState(null)
|
||||||
|
const [editingPost, setEditingPost] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.user_id) {
|
if (user?.user_id) {
|
||||||
@@ -29,29 +32,37 @@ export default function History({ user }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (postId) => {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDeleting(postId)
|
||||||
try {
|
try {
|
||||||
await api.deletePost(postId)
|
await api.deletePost(postId, user.user_id)
|
||||||
// Refresh the list
|
// Remove from local state immediately
|
||||||
fetchHistory()
|
setPosts(posts.filter(p => p.post_id !== postId))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to delete post: ' + err.message)
|
alert('Failed to delete post: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setDeleting(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleView = async (postId) => {
|
const handleView = (postId) => {
|
||||||
try {
|
if (onViewPost) {
|
||||||
const bundle = await api.getPostBundle(postId)
|
onViewPost(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 handleEdit = (post) => {
|
||||||
|
setEditingPost(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEdit = () => {
|
||||||
|
// Refresh the list after edit
|
||||||
|
fetchHistory()
|
||||||
|
}
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', {
|
||||||
@@ -106,7 +117,12 @@ export default function History({ user }) {
|
|||||||
{posts.length > 0 ? (
|
{posts.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{posts.map((post) => (
|
{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">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="font-semibold text-gray-900 mb-1">{post.title}</h4>
|
<h4 className="font-semibold text-gray-900 mb-1">{post.title}</h4>
|
||||||
|
|
||||||
@@ -144,19 +160,33 @@ export default function History({ user }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 flex-shrink-0">
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
{post.status === 'ready' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleView(post.post_id)}
|
onClick={() => handleView(post.post_id)}
|
||||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
className="p-2 text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||||
title="View details"
|
title="View full post"
|
||||||
>
|
>
|
||||||
<Eye size={18} />
|
<Eye size={18} />
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => handleDelete(post.post_id)}
|
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"
|
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} />
|
<Trash2 size={18} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +200,7 @@ export default function History({ user }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Stats */}
|
{/* Summary Stats */}
|
||||||
{posts.length > 0 && (
|
{/* {posts.length > 0 && (
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
<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>
|
<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 className="text-sm text-gray-600">Public</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{posts.length >= 20 && (
|
{posts.length >= 20 && (
|
||||||
@@ -210,6 +240,16 @@ export default function History({ user }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editingPost && (
|
||||||
|
<EditPostModal
|
||||||
|
post={editingPost}
|
||||||
|
user={user}
|
||||||
|
onClose={() => setEditingPost(null)}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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 { Search as SearchIcon, Sparkles, Clock, ExternalLink } from 'lucide-react'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
|
||||||
export default function Search({ user, initialQuery = '' }) {
|
export default function Search({ user, initialQuery = '', onViewPost}) {
|
||||||
const [query, setQuery] = useState(initialQuery)
|
const [query, setQuery] = useState(initialQuery)
|
||||||
const [results, setResults] = useState([])
|
const [results, setResults] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -48,6 +48,12 @@ export default function Search({ user, initialQuery = '' }) {
|
|||||||
performSearch(query)
|
performSearch(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleView = (postId) => {
|
||||||
|
if (onViewPost) {
|
||||||
|
onViewPost(postId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatTime = (seconds) => {
|
const formatTime = (seconds) => {
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60)
|
||||||
const secs = Math.floor(seconds % 60)
|
const secs = Math.floor(seconds % 60)
|
||||||
@@ -173,13 +179,13 @@ export default function Search({ user, initialQuery = '' }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<button
|
||||||
href={`#post-${result.post_id}`}
|
onClick={() => onViewPost && onViewPost(result.post_id)}
|
||||||
className="flex items-center gap-1 text-sm text-[#f4b840] hover:text-[#e5a930]"
|
className="flex items-center gap-1 text-sm text-[#f4b840] hover:text-[#e5a930]"
|
||||||
>
|
>
|
||||||
<span>View Post</span>
|
<span>View Post</span>
|
||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transcript Text with Highlighting */}
|
{/* Transcript Text with Highlighting */}
|
||||||
|
|||||||
Reference in New Issue
Block a user