diff --git a/backend/__pycache__/api_routes.cpython-314.pyc b/backend/__pycache__/api_routes.cpython-314.pyc index 59964d3..8d912e3 100644 Binary files a/backend/__pycache__/api_routes.cpython-314.pyc and b/backend/__pycache__/api_routes.cpython-314.pyc differ diff --git a/backend/__pycache__/db_queries.cpython-314.pyc b/backend/__pycache__/db_queries.cpython-314.pyc index e072a98..1be6bc1 100644 Binary files a/backend/__pycache__/db_queries.cpython-314.pyc and b/backend/__pycache__/db_queries.cpython-314.pyc differ diff --git a/backend/api_routes.py b/backend/api_routes.py index 2167926..ef9d66d 100644 --- a/backend/api_routes.py +++ b/backend/api_routes.py @@ -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/") +# 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//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/") +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//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) diff --git a/backend/db_queries.py b/backend/db_queries.py index 441e4fd..5dd7edd 100644 --- a/backend/db_queries.py +++ b/backend/db_queries.py @@ -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 diff --git a/frontend/index.html b/frontend/index.html index 7bf9ce9..26adc87 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ - frontend + VoiceVault
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a6564e5..d48ffae 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 + } + switch (activeTab) { case 'create': return case 'search': - return + return case 'history': - return + return case 'settings': return default: - return + return } } diff --git a/frontend/src/api.js b/frontend/src/api.js index dd85265..bdb09d8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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) { diff --git a/frontend/src/components/AudioPostCard.jsx b/frontend/src/components/AudioPostCard.jsx index 040dbfa..3c46b4b 100644 --- a/frontend/src/components/AudioPostCard.jsx +++ b/frontend/src/components/AudioPostCard.jsx @@ -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 ( @@ -178,39 +185,51 @@ export default function AudioPostCard({ post }) { }`}> {post.status} - + {post.language && ( - - {post.language.toUpperCase()} - - )} + + {post.language.toUpperCase()} + + )} - {post.status === 'ready' && ( - - )} + {/* Action Buttons */} +
+ + {post.status === 'ready' && ( + + )} + {post.status === 'ready' && ( + + + )} +
- - {/* Description */} {post.description && (

@@ -218,7 +237,6 @@ export default function AudioPostCard({ post }) {

)} - {/* Audio Player - Only show if ready */} {post.status === 'ready' && ( <> diff --git a/frontend/src/components/EditPostModal.jsx b/frontend/src/components/EditPostModal.jsx new file mode 100644 index 0000000..3d941f4 --- /dev/null +++ b/frontend/src/components/EditPostModal.jsx @@ -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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

Edit Post

+ +
+ + {/* Body */} +
+ {error && ( +
+ {error} +
+ )} + + {/* Title */} +
+ + 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} + /> +
+ + {/* Description */} +
+ +