mirror of
https://github.com/MODSetter/SurfSense.git
synced 2025-09-02 02:29:08 +00:00
feat: Added Podcast Feature and its actually fast.
- Fully Async
This commit is contained in:
parent
10d56acaa8
commit
b4bee887bd
19 changed files with 1676 additions and 75 deletions
|
@ -0,0 +1,44 @@
|
||||||
|
"""Change podcast_content to podcast_transcript with JSON type
|
||||||
|
|
||||||
|
Revision ID: 6
|
||||||
|
Revises: 5
|
||||||
|
Create Date: 2023-08-15 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSON
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '6'
|
||||||
|
down_revision: Union[str, None] = '5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Drop the old column and create a new one with the new name and type
|
||||||
|
# We need to do this because PostgreSQL doesn't support direct column renames with type changes
|
||||||
|
op.add_column('podcasts', sa.Column('podcast_transcript', JSON, nullable=False, server_default='{}'))
|
||||||
|
|
||||||
|
# Copy data from old column to new column
|
||||||
|
# Convert text to JSON by storing it as a JSON string value
|
||||||
|
op.execute("UPDATE podcasts SET podcast_transcript = jsonb_build_object('text', podcast_content) WHERE podcast_content != ''")
|
||||||
|
|
||||||
|
# Drop the old column
|
||||||
|
op.drop_column('podcasts', 'podcast_content')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Add back the original column
|
||||||
|
op.add_column('podcasts', sa.Column('podcast_content', sa.Text(), nullable=False, server_default=''))
|
||||||
|
|
||||||
|
# Copy data from JSON column back to text column
|
||||||
|
# Extract the 'text' field if it exists, otherwise use empty string
|
||||||
|
op.execute("UPDATE podcasts SET podcast_content = COALESCE((podcast_transcript->>'text'), '')")
|
||||||
|
|
||||||
|
# Drop the new column
|
||||||
|
op.drop_column('podcasts', 'podcast_transcript')
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Remove is_generated column from podcasts table
|
||||||
|
|
||||||
|
Revision ID: 7
|
||||||
|
Revises: 6
|
||||||
|
Create Date: 2023-08-15 01:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '7'
|
||||||
|
down_revision: Union[str, None] = '6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Drop the is_generated column
|
||||||
|
op.drop_column('podcasts', 'is_generated')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Add back the is_generated column with its original constraints
|
||||||
|
op.add_column('podcasts', sa.Column('is_generated', sa.Boolean(), nullable=False, server_default='false'))
|
|
@ -6,6 +6,9 @@ from .state import State
|
||||||
|
|
||||||
from .nodes import create_merged_podcast_audio, create_podcast_transcript
|
from .nodes import create_merged_podcast_audio, create_podcast_transcript
|
||||||
|
|
||||||
|
|
||||||
|
def build_graph():
|
||||||
|
|
||||||
# Define a new graph
|
# Define a new graph
|
||||||
workflow = StateGraph(State, config_schema=Configuration)
|
workflow = StateGraph(State, config_schema=Configuration)
|
||||||
|
|
||||||
|
@ -21,3 +24,8 @@ workflow.add_edge("create_merged_podcast_audio", "__end__")
|
||||||
# Compile the workflow into an executable graph
|
# Compile the workflow into an executable graph
|
||||||
graph = workflow.compile()
|
graph = workflow.compile()
|
||||||
graph.name = "Surfsense Podcaster" # This defines the custom name in LangSmith
|
graph.name = "Surfsense Podcaster" # This defines the custom name in LangSmith
|
||||||
|
|
||||||
|
return graph
|
||||||
|
|
||||||
|
# Compile the graph once when the module is loaded
|
||||||
|
graph = build_graph()
|
||||||
|
|
|
@ -28,7 +28,7 @@ async def create_podcast_transcript(state: State, config: RunnableConfig) -> Dic
|
||||||
# Create the messages
|
# Create the messages
|
||||||
messages = [
|
messages = [
|
||||||
SystemMessage(content=prompt),
|
SystemMessage(content=prompt),
|
||||||
HumanMessage(content=state.source_content)
|
HumanMessage(content=f"<source_content>{state.source_content}</source_content>")
|
||||||
]
|
]
|
||||||
|
|
||||||
# Generate the podcast transcript
|
# Generate the podcast transcript
|
||||||
|
|
|
@ -106,6 +106,6 @@ Output:
|
||||||
}}
|
}}
|
||||||
</examples>
|
</examples>
|
||||||
|
|
||||||
Transform the source material into a lively and engaging podcast conversation. Craft dialogue that showcases authentic host chemistry and natural interaction (including occasional disagreement, building on points, or asking follow-up questions). Use varied speech patterns reflecting real human conversation, ensuring the final script effectively educates *and* entertains the listener while keeping within a 3-minute audio duration.
|
Transform the source material into a lively and engaging podcast conversation. Craft dialogue that showcases authentic host chemistry and natural interaction (including occasional disagreement, building on points, or asking follow-up questions). Use varied speech patterns reflecting real human conversation, ensuring the final script effectively educates *and* entertains the listener while keeping within a 5-minute audio duration.
|
||||||
</podcast_generation_system>
|
</podcast_generation_system>
|
||||||
"""
|
"""
|
|
@ -110,8 +110,7 @@ class Podcast(BaseModel, TimestampMixin):
|
||||||
__tablename__ = "podcasts"
|
__tablename__ = "podcasts"
|
||||||
|
|
||||||
title = Column(String, nullable=False, index=True)
|
title = Column(String, nullable=False, index=True)
|
||||||
is_generated = Column(Boolean, nullable=False, default=False)
|
podcast_transcript = Column(JSON, nullable=False, default={})
|
||||||
podcast_content = Column(Text, nullable=False, default="")
|
|
||||||
file_location = Column(String(500), nullable=False, default="")
|
file_location = Column(String(500), nullable=False, default="")
|
||||||
|
|
||||||
search_space_id = Column(Integer, ForeignKey("searchspaces.id", ondelete='CASCADE'), nullable=False)
|
search_space_id = Column(Integer, ForeignKey("searchspaces.id", ondelete='CASCADE'), nullable=False)
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
from typing import List
|
from typing import List
|
||||||
from app.db import get_async_session, User, SearchSpace, Podcast
|
from app.db import get_async_session, User, SearchSpace, Podcast, Chat
|
||||||
from app.schemas import PodcastCreate, PodcastUpdate, PodcastRead
|
from app.schemas import PodcastCreate, PodcastUpdate, PodcastRead, PodcastGenerateRequest
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.check_ownership import check_ownership
|
from app.utils.check_ownership import check_ownership
|
||||||
|
from app.tasks.podcast_tasks import generate_chat_podcast
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
@ -120,3 +124,120 @@ async def delete_podcast(
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise HTTPException(status_code=500, detail="Database error occurred while deleting podcast")
|
raise HTTPException(status_code=500, detail="Database error occurred while deleting podcast")
|
||||||
|
|
||||||
|
async def generate_chat_podcast_with_new_session(
|
||||||
|
chat_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
podcast_title: str = "SurfSense Podcast"
|
||||||
|
):
|
||||||
|
"""Create a new session and process chat podcast generation."""
|
||||||
|
from app.db import async_session_maker
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
try:
|
||||||
|
await generate_chat_podcast(session, chat_id, search_space_id, podcast_title)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.error(f"Error generating podcast from chat: {str(e)}")
|
||||||
|
|
||||||
|
@router.post("/podcasts/generate/")
|
||||||
|
async def generate_podcast(
|
||||||
|
request: PodcastGenerateRequest,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
fastapi_background_tasks: BackgroundTasks = BackgroundTasks()
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
# Check if the user owns the search space
|
||||||
|
await check_ownership(session, SearchSpace, request.search_space_id, user)
|
||||||
|
|
||||||
|
if request.type == "CHAT":
|
||||||
|
# Verify that all chat IDs belong to this user and search space
|
||||||
|
query = select(Chat).filter(
|
||||||
|
Chat.id.in_(request.ids),
|
||||||
|
Chat.search_space_id == request.search_space_id
|
||||||
|
).join(SearchSpace).filter(SearchSpace.user_id == user.id)
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
valid_chats = result.scalars().all()
|
||||||
|
valid_chat_ids = [chat.id for chat in valid_chats]
|
||||||
|
|
||||||
|
# If any requested ID is not in valid IDs, raise error immediately
|
||||||
|
if len(valid_chat_ids) != len(request.ids):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="One or more chat IDs do not belong to this user or search space"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only add a single task with the first chat ID
|
||||||
|
for chat_id in valid_chat_ids:
|
||||||
|
fastapi_background_tasks.add_task(
|
||||||
|
generate_chat_podcast_with_new_session,
|
||||||
|
chat_id,
|
||||||
|
request.search_space_id,
|
||||||
|
request.podcast_title
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Podcast generation started",
|
||||||
|
}
|
||||||
|
except HTTPException as he:
|
||||||
|
raise he
|
||||||
|
except IntegrityError as e:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(status_code=400, detail="Podcast generation failed due to constraint violation")
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail="Database error occurred while generating podcast")
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}")
|
||||||
|
|
||||||
|
@router.get("/podcasts/{podcast_id}/stream")
|
||||||
|
async def stream_podcast(
|
||||||
|
podcast_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user)
|
||||||
|
):
|
||||||
|
"""Stream a podcast audio file."""
|
||||||
|
try:
|
||||||
|
# Get the podcast and check if user has access
|
||||||
|
result = await session.execute(
|
||||||
|
select(Podcast)
|
||||||
|
.join(SearchSpace)
|
||||||
|
.filter(Podcast.id == podcast_id, SearchSpace.user_id == user.id)
|
||||||
|
)
|
||||||
|
podcast = result.scalars().first()
|
||||||
|
|
||||||
|
if not podcast:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Podcast not found or you don't have permission to access it"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the file path
|
||||||
|
file_path = podcast.file_location
|
||||||
|
|
||||||
|
# Check if the file exists
|
||||||
|
if not os.path.isfile(file_path):
|
||||||
|
raise HTTPException(status_code=404, detail="Podcast audio file not found")
|
||||||
|
|
||||||
|
# Define a generator function to stream the file
|
||||||
|
def iterfile():
|
||||||
|
with open(file_path, mode="rb") as file_like:
|
||||||
|
yield from file_like
|
||||||
|
|
||||||
|
# Return a streaming response with appropriate headers
|
||||||
|
return StreamingResponse(
|
||||||
|
iterfile(),
|
||||||
|
media_type="audio/mpeg",
|
||||||
|
headers={
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Disposition": f"inline; filename={Path(file_path).name}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException as he:
|
||||||
|
raise he
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error streaming podcast: {str(e)}")
|
|
@ -10,7 +10,7 @@ from .documents import (
|
||||||
DocumentRead,
|
DocumentRead,
|
||||||
)
|
)
|
||||||
from .chunks import ChunkBase, ChunkCreate, ChunkUpdate, ChunkRead
|
from .chunks import ChunkBase, ChunkCreate, ChunkUpdate, ChunkRead
|
||||||
from .podcasts import PodcastBase, PodcastCreate, PodcastUpdate, PodcastRead
|
from .podcasts import PodcastBase, PodcastCreate, PodcastUpdate, PodcastRead, PodcastGenerateRequest
|
||||||
from .chats import ChatBase, ChatCreate, ChatUpdate, ChatRead, AISDKChatRequest
|
from .chats import ChatBase, ChatCreate, ChatUpdate, ChatRead, AISDKChatRequest
|
||||||
from .search_source_connector import SearchSourceConnectorBase, SearchSourceConnectorCreate, SearchSourceConnectorUpdate, SearchSourceConnectorRead
|
from .search_source_connector import SearchSourceConnectorBase, SearchSourceConnectorCreate, SearchSourceConnectorUpdate, SearchSourceConnectorRead
|
||||||
|
|
||||||
|
@ -39,6 +39,7 @@ __all__ = [
|
||||||
"PodcastCreate",
|
"PodcastCreate",
|
||||||
"PodcastUpdate",
|
"PodcastUpdate",
|
||||||
"PodcastRead",
|
"PodcastRead",
|
||||||
|
"PodcastGenerateRequest",
|
||||||
"ChatBase",
|
"ChatBase",
|
||||||
"ChatCreate",
|
"ChatCreate",
|
||||||
"ChatUpdate",
|
"ChatUpdate",
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import JSON
|
|
||||||
from .base import IDModel, TimestampModel
|
|
||||||
from app.db import ChatType
|
from app.db import ChatType
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .base import IDModel, TimestampModel
|
||||||
|
|
||||||
|
|
||||||
class ChatBase(BaseModel):
|
class ChatBase(BaseModel):
|
||||||
type: ChatType
|
type: ChatType
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from typing import Any, List, Literal
|
||||||
from .base import IDModel, TimestampModel
|
from .base import IDModel, TimestampModel
|
||||||
|
|
||||||
class PodcastBase(BaseModel):
|
class PodcastBase(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
is_generated: bool = False
|
podcast_transcript: List[Any]
|
||||||
podcast_content: str = ""
|
|
||||||
file_location: str = ""
|
file_location: str = ""
|
||||||
search_space_id: int
|
search_space_id: int
|
||||||
|
|
||||||
|
@ -17,3 +17,9 @@ class PodcastUpdate(PodcastBase):
|
||||||
class PodcastRead(PodcastBase, IDModel, TimestampModel):
|
class PodcastRead(PodcastBase, IDModel, TimestampModel):
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
class PodcastGenerateRequest(BaseModel):
|
||||||
|
type: Literal["DOCUMENT", "CHAT"]
|
||||||
|
ids: List[int]
|
||||||
|
search_space_id: int
|
||||||
|
podcast_title: str = "SurfSense Podcast"
|
94
surfsense_backend/app/tasks/podcast_tasks.py
Normal file
94
surfsense_backend/app/tasks/podcast_tasks.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.schemas import PodcastGenerateRequest
|
||||||
|
from typing import List
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.db import Chat, Podcast
|
||||||
|
from app.agents.podcaster.graph import graph as podcaster_graph
|
||||||
|
from surfsense_backend.app.agents.podcaster.state import State
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_document_podcast(
|
||||||
|
session: AsyncSession,
|
||||||
|
document_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: int
|
||||||
|
):
|
||||||
|
# TODO: Need to fetch the document chunks, then concatenate them and pass them to the podcast generation model
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_chat_podcast(
|
||||||
|
session: AsyncSession,
|
||||||
|
chat_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
podcast_title: str
|
||||||
|
):
|
||||||
|
# Fetch the chat with the specified ID
|
||||||
|
query = select(Chat).filter(
|
||||||
|
Chat.id == chat_id,
|
||||||
|
Chat.search_space_id == search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
chat = result.scalars().first()
|
||||||
|
|
||||||
|
if not chat:
|
||||||
|
raise ValueError(f"Chat with id {chat_id} not found in search space {search_space_id}")
|
||||||
|
|
||||||
|
# Create chat history structure
|
||||||
|
chat_history_str = "<chat_history>"
|
||||||
|
|
||||||
|
for message in chat.messages:
|
||||||
|
if message["role"] == "user":
|
||||||
|
chat_history_str += f"<user_message>{message['content']}</user_message>"
|
||||||
|
elif message["role"] == "assistant":
|
||||||
|
# Last annotation type will always be "ANSWER" here
|
||||||
|
answer_annotation = message["annotations"][-1]
|
||||||
|
answer_text = ""
|
||||||
|
if answer_annotation["type"] == "ANSWER":
|
||||||
|
answer_text = answer_annotation["content"]
|
||||||
|
# If content is a list, join it into a single string
|
||||||
|
if isinstance(answer_text, list):
|
||||||
|
answer_text = "\n".join(answer_text)
|
||||||
|
chat_history_str += f"<assistant_message>{answer_text}</assistant_message>"
|
||||||
|
|
||||||
|
chat_history_str += "</chat_history>"
|
||||||
|
|
||||||
|
# Pass it to the SurfSense Podcaster
|
||||||
|
config = {
|
||||||
|
"configurable": {
|
||||||
|
"podcast_title" : "Surfsense",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Initialize state with database session and streaming service
|
||||||
|
initial_state = State(
|
||||||
|
source_content=chat_history_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run the graph directly
|
||||||
|
result = await podcaster_graph.ainvoke(initial_state, config=config)
|
||||||
|
|
||||||
|
# Convert podcast transcript entries to serializable format
|
||||||
|
serializable_transcript = []
|
||||||
|
for entry in result["podcast_transcript"]:
|
||||||
|
serializable_transcript.append({
|
||||||
|
"speaker_id": entry.speaker_id,
|
||||||
|
"dialog": entry.dialog
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create a new podcast entry
|
||||||
|
podcast = Podcast(
|
||||||
|
title=f"{podcast_title}",
|
||||||
|
podcast_transcript=serializable_transcript,
|
||||||
|
file_location=result["final_podcast_file_path"],
|
||||||
|
search_space_id=search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to session and commit
|
||||||
|
session.add(podcast)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(podcast)
|
||||||
|
|
||||||
|
return podcast
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { MessageCircleMore, Search, Calendar, Tag, Trash2, ExternalLink, MoreHorizontal } from 'lucide-react';
|
import { MessageCircleMore, Search, Calendar, Tag, Trash2, ExternalLink, MoreHorizontal, Radio, CheckCircle, Circle, Podcast } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
// UI Components
|
// UI Components
|
||||||
|
@ -42,6 +42,9 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Chat {
|
interface Chat {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
@ -92,6 +95,18 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
const [chatToDelete, setChatToDelete] = useState<{ id: number, title: string } | null>(null);
|
const [chatToDelete, setChatToDelete] = useState<{ id: number, title: string } | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
// New state for podcast generation
|
||||||
|
const [selectedChats, setSelectedChats] = useState<number[]>([]);
|
||||||
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
|
const [podcastDialogOpen, setPodcastDialogOpen] = useState(false);
|
||||||
|
const [podcastTitle, setPodcastTitle] = useState("");
|
||||||
|
const [isGeneratingPodcast, setIsGeneratingPodcast] = useState(false);
|
||||||
|
|
||||||
|
// New state for individual podcast generation
|
||||||
|
const [currentChatIndex, setCurrentChatIndex] = useState(0);
|
||||||
|
const [podcastTitles, setPodcastTitles] = useState<{[key: number]: string}>({});
|
||||||
|
const [processingChat, setProcessingChat] = useState<Chat | null>(null);
|
||||||
|
|
||||||
const chatsPerPage = 9;
|
const chatsPerPage = 9;
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
@ -234,6 +249,177 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
// Get unique chat types for filter dropdown
|
// Get unique chat types for filter dropdown
|
||||||
const chatTypes = ['all', ...Array.from(new Set(chats.map(chat => chat.type)))];
|
const chatTypes = ['all', ...Array.from(new Set(chats.map(chat => chat.type)))];
|
||||||
|
|
||||||
|
// Generate individual podcasts from selected chats
|
||||||
|
const handleGeneratePodcast = async () => {
|
||||||
|
if (selectedChats.length === 0) {
|
||||||
|
toast.error("Please select at least one chat");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentChatId = selectedChats[currentChatIndex];
|
||||||
|
const currentTitle = podcastTitles[currentChatId] || podcastTitle;
|
||||||
|
|
||||||
|
if (!currentTitle.trim()) {
|
||||||
|
toast.error("Please enter a podcast title");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingPodcast(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('surfsense_bearer_token');
|
||||||
|
if (!token) {
|
||||||
|
toast.error("Authentication error. Please log in again.");
|
||||||
|
setIsGeneratingPodcast(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create payload for single chat
|
||||||
|
const payload = {
|
||||||
|
type: "CHAT",
|
||||||
|
ids: [currentChatId], // Single chat ID
|
||||||
|
search_space_id: parseInt(searchSpaceId),
|
||||||
|
podcast_title: currentTitle
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/generate/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to generate podcast");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
toast.success(`Podcast "${currentTitle}" generation started!`);
|
||||||
|
|
||||||
|
// Move to the next chat or finish
|
||||||
|
if (currentChatIndex < selectedChats.length - 1) {
|
||||||
|
// Set up for next chat
|
||||||
|
setCurrentChatIndex(currentChatIndex + 1);
|
||||||
|
|
||||||
|
// Find the next chat from the chats array
|
||||||
|
const nextChatId = selectedChats[currentChatIndex + 1];
|
||||||
|
const nextChat = chats.find(chat => chat.id === nextChatId) || null;
|
||||||
|
setProcessingChat(nextChat);
|
||||||
|
|
||||||
|
// Default title for the next chat
|
||||||
|
if (!podcastTitles[nextChatId]) {
|
||||||
|
setPodcastTitle(nextChat?.title || `Podcast from Chat ${nextChatId}`);
|
||||||
|
} else {
|
||||||
|
setPodcastTitle(podcastTitles[nextChatId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingPodcast(false);
|
||||||
|
} else {
|
||||||
|
// All done
|
||||||
|
finishPodcastGeneration();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating podcast:', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to generate podcast');
|
||||||
|
setIsGeneratingPodcast(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to finish the podcast generation process
|
||||||
|
const finishPodcastGeneration = () => {
|
||||||
|
toast.success("All podcasts are being generated! Check the podcasts tab to see them when ready.");
|
||||||
|
setPodcastDialogOpen(false);
|
||||||
|
setSelectedChats([]);
|
||||||
|
setSelectionMode(false);
|
||||||
|
setCurrentChatIndex(0);
|
||||||
|
setPodcastTitles({});
|
||||||
|
setProcessingChat(null);
|
||||||
|
setPodcastTitle("");
|
||||||
|
setIsGeneratingPodcast(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start podcast generation flow
|
||||||
|
const startPodcastGeneration = () => {
|
||||||
|
if (selectedChats.length === 0) {
|
||||||
|
toast.error("Please select at least one chat");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the state for podcast generation
|
||||||
|
setCurrentChatIndex(0);
|
||||||
|
setPodcastTitles({});
|
||||||
|
|
||||||
|
// Set up for the first chat
|
||||||
|
const firstChatId = selectedChats[0];
|
||||||
|
const firstChat = chats.find(chat => chat.id === firstChatId) || null;
|
||||||
|
setProcessingChat(firstChat);
|
||||||
|
|
||||||
|
// Set default title for the first chat
|
||||||
|
setPodcastTitle(firstChat?.title || `Podcast from Chat ${firstChatId}`);
|
||||||
|
setPodcastDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the title for the current chat
|
||||||
|
const updateCurrentChatTitle = (title: string) => {
|
||||||
|
const currentChatId = selectedChats[currentChatIndex];
|
||||||
|
setPodcastTitle(title);
|
||||||
|
setPodcastTitles(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentChatId]: title
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip generating a podcast for the current chat
|
||||||
|
const skipCurrentChat = () => {
|
||||||
|
if (currentChatIndex < selectedChats.length - 1) {
|
||||||
|
// Move to the next chat
|
||||||
|
setCurrentChatIndex(currentChatIndex + 1);
|
||||||
|
|
||||||
|
// Find the next chat
|
||||||
|
const nextChatId = selectedChats[currentChatIndex + 1];
|
||||||
|
const nextChat = chats.find(chat => chat.id === nextChatId) || null;
|
||||||
|
setProcessingChat(nextChat);
|
||||||
|
|
||||||
|
// Set default title for the next chat
|
||||||
|
if (!podcastTitles[nextChatId]) {
|
||||||
|
setPodcastTitle(nextChat?.title || `Podcast from Chat ${nextChatId}`);
|
||||||
|
} else {
|
||||||
|
setPodcastTitle(podcastTitles[nextChatId]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// All done (all skipped)
|
||||||
|
finishPodcastGeneration();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle chat selection
|
||||||
|
const toggleChatSelection = (chatId: number) => {
|
||||||
|
setSelectedChats(prev =>
|
||||||
|
prev.includes(chatId)
|
||||||
|
? prev.filter(id => id !== chatId)
|
||||||
|
: [...prev, chatId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select all visible chats
|
||||||
|
const selectAllVisibleChats = () => {
|
||||||
|
const visibleChatIds = currentChats.map(chat => chat.id);
|
||||||
|
setSelectedChats(prev => {
|
||||||
|
const allSelected = visibleChatIds.every(id => prev.includes(id));
|
||||||
|
return allSelected
|
||||||
|
? prev.filter(id => !visibleChatIds.includes(id)) // Deselect all visible if all are selected
|
||||||
|
: [...new Set([...prev, ...visibleChatIds])]; // Add all visible, ensuring no duplicates
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel selection mode
|
||||||
|
const cancelSelectionMode = () => {
|
||||||
|
setSelectionMode(false);
|
||||||
|
setSelectedChats([]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="container p-6 mx-auto"
|
className="container p-6 mx-auto"
|
||||||
|
@ -278,7 +464,49 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
|
{selectionMode ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={selectAllVisibleChats}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
{currentChats.every(chat => selectedChats.includes(chat.id))
|
||||||
|
? "Deselect All"
|
||||||
|
: "Select All"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={startPodcastGeneration}
|
||||||
|
className="gap-1"
|
||||||
|
disabled={selectedChats.length === 0}
|
||||||
|
>
|
||||||
|
<Podcast className="h-4 w-4" />
|
||||||
|
Generate Podcast ({selectedChats.length})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={cancelSelectionMode}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectionMode(true)}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Podcast className="h-4 w-4" />
|
||||||
|
Podcaster
|
||||||
|
</Button>
|
||||||
<Select value={sortOrder} onValueChange={setSortOrder}>
|
<Select value={sortOrder} onValueChange={setSortOrder}>
|
||||||
<SelectTrigger className="w-40">
|
<SelectTrigger className="w-40">
|
||||||
<SelectValue placeholder="Sort order" />
|
<SelectValue placeholder="Sort order" />
|
||||||
|
@ -290,6 +518,8 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -334,11 +564,22 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
animate="animate"
|
animate="animate"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||||
className="overflow-hidden hover:shadow-md transition-shadow"
|
className={`overflow-hidden hover:shadow-md transition-shadow
|
||||||
|
${selectionMode && selectedChats.includes(chat.id)
|
||||||
|
? 'ring-2 ring-primary ring-offset-2' : ''}`}
|
||||||
|
onClick={() => selectionMode ? toggleChatSelection(chat.id) : null}
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1 flex items-start gap-2">
|
||||||
|
{selectionMode && (
|
||||||
|
<div className="mt-1">
|
||||||
|
{selectedChats.includes(chat.id)
|
||||||
|
? <CheckCircle className="h-4 w-4 text-primary" />
|
||||||
|
: <Circle className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
<CardTitle className="line-clamp-1">{chat.title || `Chat ${chat.id}`}</CardTitle>
|
<CardTitle className="line-clamp-1">{chat.title || `Chat ${chat.id}`}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
|
@ -347,6 +588,8 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
</span>
|
</span>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{!selectionMode && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
@ -359,10 +602,21 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
<span>View Chat</span>
|
<span>View Chat</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedChats([chat.id]);
|
||||||
|
setPodcastTitle(chat.title || `Chat ${chat.id}`);
|
||||||
|
setPodcastDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Podcast className="mr-2 h-4 w-4" />
|
||||||
|
<span>Generate Podcast</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
setChatToDelete({ id: chat.id, title: chat.title || `Chat ${chat.id}` });
|
setChatToDelete({ id: chat.id, title: chat.title || `Chat ${chat.id}` });
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
|
@ -372,6 +626,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
@ -505,6 +760,104 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Podcast Generation Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={podcastDialogOpen}
|
||||||
|
onOpenChange={(isOpen: boolean) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
// Cancel the process if dialog is closed
|
||||||
|
setPodcastDialogOpen(false);
|
||||||
|
setSelectedChats([]);
|
||||||
|
setSelectionMode(false);
|
||||||
|
setCurrentChatIndex(0);
|
||||||
|
setPodcastTitles({});
|
||||||
|
setProcessingChat(null);
|
||||||
|
setPodcastTitle("");
|
||||||
|
} else {
|
||||||
|
setPodcastDialogOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Podcast className="h-5 w-5 text-primary" />
|
||||||
|
<span>Generate Podcast {currentChatIndex + 1} of {selectedChats.length}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectedChats.length > 1 ? (
|
||||||
|
<>Creating individual podcasts for each selected chat. Currently processing: <span className="font-medium">{processingChat?.title || `Chat ${selectedChats[currentChatIndex]}`}</span></>
|
||||||
|
) : (
|
||||||
|
<>Create a podcast from this chat. The podcast will be available in the podcasts section once generated.</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="podcast-title">Podcast Title</Label>
|
||||||
|
<Input
|
||||||
|
id="podcast-title"
|
||||||
|
placeholder="Enter podcast title"
|
||||||
|
value={podcastTitle}
|
||||||
|
onChange={(e) => updateCurrentChatTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedChats.length > 1 && (
|
||||||
|
<div className="w-full bg-muted rounded-full h-2.5 mt-4">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-2.5 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${((currentChatIndex) / selectedChats.length) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
|
{selectedChats.length > 1 && !isGeneratingPodcast && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={skipCurrentChat}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setPodcastDialogOpen(false);
|
||||||
|
setCurrentChatIndex(0);
|
||||||
|
setPodcastTitles({});
|
||||||
|
setProcessingChat(null);
|
||||||
|
}}
|
||||||
|
disabled={isGeneratingPodcast}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleGeneratePodcast}
|
||||||
|
disabled={isGeneratingPodcast}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isGeneratingPodcast ? (
|
||||||
|
<>
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Podcast className="h-4 w-4" />
|
||||||
|
Generate Podcast
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -73,6 +73,13 @@ export default function DashboardLayout({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Podcasts",
|
||||||
|
url: `/dashboard/${search_space_id}/podcasts`,
|
||||||
|
icon: "Podcast",
|
||||||
|
items: [
|
||||||
|
],
|
||||||
|
}
|
||||||
// TODO: Add research synthesizer's
|
// TODO: Add research synthesizer's
|
||||||
// {
|
// {
|
||||||
// title: "Research Synthesizer's",
|
// title: "Research Synthesizer's",
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import PodcastsPageClient from './podcasts-client';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: {
|
||||||
|
search_space_id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PodcastsPage({ params }: PageProps) {
|
||||||
|
// Access dynamic route parameters
|
||||||
|
// Need to await params before accessing its properties in an async component
|
||||||
|
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="flex items-center justify-center h-[60vh]">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||||
|
</div>}>
|
||||||
|
<PodcastsPageClient searchSpaceId={searchSpaceId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,787 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import {
|
||||||
|
Search, Calendar, Trash2, MoreHorizontal, Podcast,
|
||||||
|
Play, Pause, SkipForward, SkipBack, Volume2, VolumeX
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// UI Components
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Podcast {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
created_at: string;
|
||||||
|
file_location: string;
|
||||||
|
podcast_transcript: any[];
|
||||||
|
search_space_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PodcastsPageClientProps {
|
||||||
|
searchSpaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageVariants = {
|
||||||
|
initial: { opacity: 0 },
|
||||||
|
enter: { opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
|
||||||
|
exit: { opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
const podcastCardVariants = {
|
||||||
|
initial: { y: 20, opacity: 0 },
|
||||||
|
animate: { y: 0, opacity: 1 },
|
||||||
|
exit: { y: -20, opacity: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const MotionCard = motion(Card);
|
||||||
|
|
||||||
|
export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) {
|
||||||
|
const [podcasts, setPodcasts] = useState<Podcast[]>([]);
|
||||||
|
const [filteredPodcasts, setFilteredPodcasts] = useState<Podcast[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [sortOrder, setSortOrder] = useState<string>('newest');
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [podcastToDelete, setPodcastToDelete] = useState<{ id: number, title: string } | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Audio player state
|
||||||
|
const [currentPodcast, setCurrentPodcast] = useState<Podcast | null>(null);
|
||||||
|
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
|
||||||
|
const [isAudioLoading, setIsAudioLoading] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [volume, setVolume] = useState(0.7);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const currentObjectUrlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Add podcast image URL constant
|
||||||
|
const PODCAST_IMAGE_URL = "https://static.vecteezy.com/system/resources/thumbnails/002/157/611/small_2x/illustrations-concept-design-podcast-channel-free-vector.jpg";
|
||||||
|
|
||||||
|
// Fetch podcasts from API
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPodcasts = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Get token from localStorage
|
||||||
|
const token = localStorage.getItem('surfsense_bearer_token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setError('Authentication token not found. Please log in again.');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all podcasts for this search space
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null);
|
||||||
|
throw new Error(`Failed to fetch podcasts: ${response.status} ${errorData?.detail || ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Podcast[] = await response.json();
|
||||||
|
setPodcasts(data);
|
||||||
|
setFilteredPodcasts(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching podcasts:', error);
|
||||||
|
setError(error instanceof Error ? error.message : 'Unknown error occurred');
|
||||||
|
setPodcasts([]);
|
||||||
|
setFilteredPodcasts([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPodcasts();
|
||||||
|
}, [searchSpaceId]);
|
||||||
|
|
||||||
|
// Filter and sort podcasts based on search query and sort order
|
||||||
|
useEffect(() => {
|
||||||
|
let result = [...podcasts];
|
||||||
|
|
||||||
|
// Filter by search term
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(podcast =>
|
||||||
|
podcast.title.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search space
|
||||||
|
result = result.filter(podcast =>
|
||||||
|
podcast.search_space_id === parseInt(searchSpaceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort podcasts
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.created_at).getTime();
|
||||||
|
const dateB = new Date(b.created_at).getTime();
|
||||||
|
|
||||||
|
return sortOrder === 'newest' ? dateB - dateA : dateA - dateB;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredPodcasts(result);
|
||||||
|
}, [podcasts, searchQuery, sortOrder, searchSpaceId]);
|
||||||
|
|
||||||
|
// Cleanup object URL on unmount or when currentPodcast changes
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (currentObjectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(currentObjectUrlRef.current);
|
||||||
|
currentObjectUrlRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Audio player time update handler
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
setCurrentTime(audioRef.current.currentTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Audio player metadata loaded handler
|
||||||
|
const handleMetadataLoaded = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
setDuration(audioRef.current.duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Play/pause toggle
|
||||||
|
const togglePlayPause = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seek to position
|
||||||
|
const handleSeek = (value: number[]) => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = value[0];
|
||||||
|
setCurrentTime(value[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Volume change
|
||||||
|
const handleVolumeChange = (value: number[]) => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
const newVolume = value[0];
|
||||||
|
audioRef.current.volume = newVolume;
|
||||||
|
setVolume(newVolume);
|
||||||
|
|
||||||
|
if (newVolume === 0) {
|
||||||
|
setIsMuted(true);
|
||||||
|
} else if (isMuted) {
|
||||||
|
setIsMuted(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle mute
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.muted = !isMuted;
|
||||||
|
setIsMuted(!isMuted);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip forward 10 seconds
|
||||||
|
const skipForward = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = Math.min(audioRef.current.duration, audioRef.current.currentTime + 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip backward 10 seconds
|
||||||
|
const skipBackward = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format time in MM:SS
|
||||||
|
const formatTime = (time: number) => {
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = Math.floor(time % 60);
|
||||||
|
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Play podcast - Fetch blob and set object URL
|
||||||
|
const playPodcast = async (podcast: Podcast) => {
|
||||||
|
// If the same podcast is selected, just toggle play/pause
|
||||||
|
if (currentPodcast && currentPodcast.id === podcast.id) {
|
||||||
|
togglePlayPause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke previous object URL if exists
|
||||||
|
if (currentObjectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(currentObjectUrlRef.current);
|
||||||
|
currentObjectUrlRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset player state and show loading
|
||||||
|
setCurrentPodcast(podcast);
|
||||||
|
setAudioSrc(undefined);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setIsAudioLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('surfsense_bearer_token');
|
||||||
|
if (!token) {
|
||||||
|
toast.error('Authentication token not found.');
|
||||||
|
setIsAudioLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcast.id}/stream`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch audio stream: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
currentObjectUrlRef.current = objectUrl;
|
||||||
|
setAudioSrc(objectUrl);
|
||||||
|
|
||||||
|
// Let the audio element load the new src
|
||||||
|
setTimeout(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.load();
|
||||||
|
audioRef.current.play()
|
||||||
|
.then(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error playing audio:', error);
|
||||||
|
toast.error('Failed to play audio.');
|
||||||
|
setIsPlaying(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching or playing podcast:', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to load podcast audio.');
|
||||||
|
setCurrentPodcast(null);
|
||||||
|
} finally {
|
||||||
|
setIsAudioLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to handle podcast deletion
|
||||||
|
const handleDeletePodcast = async () => {
|
||||||
|
if (!podcastToDelete) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('surfsense_bearer_token');
|
||||||
|
if (!token) {
|
||||||
|
setIsDeleting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastToDelete.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete podcast: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dialog and refresh podcasts
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setPodcastToDelete(null);
|
||||||
|
|
||||||
|
// Update local state by removing the deleted podcast
|
||||||
|
setPodcasts(prevPodcasts => prevPodcasts.filter(podcast => podcast.id !== podcastToDelete.id));
|
||||||
|
|
||||||
|
// If the current playing podcast is deleted, stop playback
|
||||||
|
if (currentPodcast && currentPodcast.id === podcastToDelete.id) {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
}
|
||||||
|
setCurrentPodcast(null);
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Podcast deleted successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting podcast:', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to delete podcast');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="container p-6 mx-auto"
|
||||||
|
initial="initial"
|
||||||
|
animate="enter"
|
||||||
|
exit="exit"
|
||||||
|
variants={pageVariants}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col space-y-4 md:space-y-6">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Podcasts</h1>
|
||||||
|
<p className="text-muted-foreground">Listen to generated podcasts.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter and Search Bar */}
|
||||||
|
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<div className="relative w-full md:w-80">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search podcasts..."
|
||||||
|
className="pl-8"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Select value={sortOrder} onValueChange={setSortOrder}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="Sort order" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="newest">Newest First</SelectItem>
|
||||||
|
<SelectItem value="oldest">Oldest First</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Messages */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center h-40">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||||
|
<p className="text-sm text-muted-foreground">Loading podcasts...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !isLoading && (
|
||||||
|
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
|
||||||
|
<h3 className="font-medium">Error loading podcasts</h3>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && filteredPodcasts.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
|
||||||
|
<Podcast className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<h3 className="font-medium">No podcasts found</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{searchQuery
|
||||||
|
? 'Try adjusting your search filters'
|
||||||
|
: 'Generate podcasts from your chats to get started'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Podcast Grid */}
|
||||||
|
{!isLoading && !error && filteredPodcasts.length > 0 && (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredPodcasts.map((podcast, index) => (
|
||||||
|
<MotionCard
|
||||||
|
key={podcast.id}
|
||||||
|
variants={podcastCardVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||||
|
className={`
|
||||||
|
bg-card/60 dark:bg-card/40 backdrop-blur-lg rounded-xl p-4
|
||||||
|
shadow-lg hover:shadow-xl transition-all duration-300
|
||||||
|
border-border overflow-hidden
|
||||||
|
${currentPodcast?.id === podcast.id ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : ''}
|
||||||
|
`}
|
||||||
|
layout
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden group cursor-pointer"
|
||||||
|
onClick={() => playPodcast(podcast)}
|
||||||
|
>
|
||||||
|
{/* Podcast image */}
|
||||||
|
<img
|
||||||
|
src={PODCAST_IMAGE_URL}
|
||||||
|
alt="Podcast illustration"
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105 brightness-[0.85] contrast-[1.1]"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overlay for better contrast with controls */}
|
||||||
|
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/30 transition-colors"></div>
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{currentPodcast?.id === podcast.id && isAudioLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Play button */}
|
||||||
|
{!(currentPodcast?.id === podcast.id && (isPlaying || isAudioLoading)) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-14 w-14 rounded-full
|
||||||
|
bg-background/70 hover:bg-background/90 backdrop-blur-sm scale-90 group-hover:scale-100
|
||||||
|
transition-transform duration-200 z-0 shadow-lg"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
playPodcast(podcast);
|
||||||
|
}}
|
||||||
|
disabled={isAudioLoading}
|
||||||
|
>
|
||||||
|
<Play className="h-7 w-7 ml-1" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pause button */}
|
||||||
|
{currentPodcast?.id === podcast.id && isPlaying && !isAudioLoading && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-14 w-14 rounded-full
|
||||||
|
bg-background/70 hover:bg-background/90 backdrop-blur-sm scale-90 group-hover:scale-100
|
||||||
|
transition-transform duration-200 z-0 shadow-lg"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
togglePlayPause();
|
||||||
|
}}
|
||||||
|
disabled={isAudioLoading}
|
||||||
|
>
|
||||||
|
<Pause className="h-7 w-7" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 px-1">
|
||||||
|
<h3 className="text-base font-semibold text-foreground truncate" title={podcast.title}>
|
||||||
|
{podcast.title || 'Untitled Podcast'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{format(new Date(podcast.created_at), 'MMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
||||||
|
<div className="mb-3 px-1">
|
||||||
|
<div
|
||||||
|
className="h-1.5 bg-muted rounded-full cursor-pointer group relative"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!audioRef.current || !duration) return;
|
||||||
|
const container = e.currentTarget;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
const newTime = percentage * duration;
|
||||||
|
handleSeek([newTime]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary rounded-full relative transition-all duration-75 ease-linear"
|
||||||
|
style={{ width: `${(currentTime / duration) * 100}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3
|
||||||
|
bg-primary rounded-full shadow-md transform scale-0 translate-x-1/2
|
||||||
|
group-hover:scale-100 transition-transform"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1.5 text-xs text-muted-foreground">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
||||||
|
<div className="flex items-center justify-between px-2 mt-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={skipBackward}
|
||||||
|
className="w-9 h-9 text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
title="Rewind 10 seconds"
|
||||||
|
disabled={!duration}
|
||||||
|
>
|
||||||
|
<SkipBack className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={togglePlayPause}
|
||||||
|
className="w-10 h-10 text-primary hover:bg-primary/10 rounded-full transition-colors"
|
||||||
|
disabled={!duration}
|
||||||
|
>
|
||||||
|
{isPlaying ?
|
||||||
|
<Pause className="w-6 h-6" /> :
|
||||||
|
<Play className="w-6 h-6 ml-0.5" />
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={skipForward}
|
||||||
|
className="w-9 h-9 text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
title="Forward 10 seconds"
|
||||||
|
disabled={!duration}
|
||||||
|
>
|
||||||
|
<SkipForward className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute top-2 right-2 z-20">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 bg-background/50 hover:bg-background/80 rounded-full backdrop-blur-sm">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setPodcastToDelete({ id: podcast.id, title: podcast.title });
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
<span>Delete Podcast</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</MotionCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Podcast Player (Fixed at bottom) */}
|
||||||
|
{currentPodcast && !isAudioLoading && audioSrc && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 100, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 100, opacity: 0 }}
|
||||||
|
className="fixed bottom-0 left-0 right-0 bg-background border-t p-4 shadow-lg z-50"
|
||||||
|
>
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-12 h-12 bg-primary/20 rounded-md flex items-center justify-center">
|
||||||
|
<Podcast className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-grow min-w-0">
|
||||||
|
<h4 className="font-medium text-sm line-clamp-1">{currentPodcast.title}</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Slider
|
||||||
|
value={[currentTime]}
|
||||||
|
min={0}
|
||||||
|
max={duration || 100}
|
||||||
|
step={0.1}
|
||||||
|
onValueChange={handleSeek}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={skipBackward}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<SkipBack className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
onClick={togglePlayPause}
|
||||||
|
className="h-10 w-10 rounded-full"
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={skipForward}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<SkipForward className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-2 ml-4 w-28">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
value={[isMuted ? 0 : volume]}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
onValueChange={handleVolumeChange}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Trash2 className="h-5 w-5 text-destructive" />
|
||||||
|
<span>Delete Podcast</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete <span className="font-medium">{podcastToDelete?.title}</span>? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeletePodcast}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Hidden audio element for playback */}
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioSrc}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleMetadataLoaded}
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('Audio error:', e);
|
||||||
|
if (audioRef.current?.error?.code !== audioRef.current?.error?.MEDIA_ERR_ABORTED) {
|
||||||
|
toast.error('Error playing audio.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ import {
|
||||||
Info,
|
Info,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Podcast,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
|
@ -45,7 +46,8 @@ export const iconMap: Record<string, LucideIcon> = {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Info,
|
Info,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Trash2
|
Trash2,
|
||||||
|
Podcast
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
|
|
28
surfsense_web/components/ui/slider.tsx
Normal file
28
surfsense_web/components/ui/slider.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
))
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Slider }
|
|
@ -28,6 +28,7 @@
|
||||||
"@radix-ui/react-popover": "^1.1.6",
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
|
"@radix-ui/react-slider": "^1.3.4",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
|
98
surfsense_web/pnpm-lock.yaml
generated
98
surfsense_web/pnpm-lock.yaml
generated
|
@ -47,6 +47,9 @@ importers:
|
||||||
'@radix-ui/react-separator':
|
'@radix-ui/react-separator':
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
version: 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
'@radix-ui/react-slider':
|
||||||
|
specifier: ^1.3.4
|
||||||
|
version: 1.3.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.2(@types/react@19.0.10)(react@19.0.0)
|
version: 1.1.2(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
@ -960,6 +963,19 @@ packages:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-collection@1.1.6':
|
||||||
|
resolution: {integrity: sha512-PbhRFK4lIEw9ADonj48tiYWzkllz81TM7KVYyyMMw2cwHO7D5h4XKEblL8NlaRisTK3QTe6tBEhDccFUryxHBQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-compose-refs@1.0.0':
|
'@radix-ui/react-compose-refs@1.0.0':
|
||||||
resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==}
|
resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -1480,6 +1496,19 @@ packages:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-primitive@2.1.2':
|
||||||
|
resolution: {integrity: sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-roving-focus@1.1.2':
|
'@radix-ui/react-roving-focus@1.1.2':
|
||||||
resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==}
|
resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -1545,6 +1574,19 @@ packages:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-slider@1.3.4':
|
||||||
|
resolution: {integrity: sha512-Cp6hEmQtRJFci285vkdIJ+HCDLTRDk+25VhFwa1fcubywjMUE3PynBgtN5RLudOgSCYMlT4jizCXdmV+8J7Y2w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-slot@1.0.0':
|
'@radix-ui/react-slot@1.0.0':
|
||||||
resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==}
|
resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -1577,6 +1619,15 @@ packages:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-slot@1.2.2':
|
||||||
|
resolution: {integrity: sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-tabs@1.1.3':
|
'@radix-ui/react-tabs@1.1.3':
|
||||||
resolution: {integrity: sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==}
|
resolution: {integrity: sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -5094,6 +5145,18 @@ snapshots:
|
||||||
'@types/react': 19.0.10
|
'@types/react': 19.0.10
|
||||||
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
||||||
|
|
||||||
|
'@radix-ui/react-collection@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
'@radix-ui/react-slot': 1.2.2(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
react: 19.0.0
|
||||||
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.0.10
|
||||||
|
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
||||||
|
|
||||||
'@radix-ui/react-compose-refs@1.0.0(react@19.0.0)':
|
'@radix-ui/react-compose-refs@1.0.0(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.26.9
|
'@babel/runtime': 7.26.9
|
||||||
|
@ -5654,6 +5717,15 @@ snapshots:
|
||||||
'@types/react': 19.0.10
|
'@types/react': 19.0.10
|
||||||
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
||||||
|
|
||||||
|
'@radix-ui/react-primitive@2.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-slot': 1.2.2(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
react: 19.0.0
|
||||||
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.0.10
|
||||||
|
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
||||||
|
|
||||||
'@radix-ui/react-roving-focus@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
'@radix-ui/react-roving-focus@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.1
|
'@radix-ui/primitive': 1.1.1
|
||||||
|
@ -5743,6 +5815,25 @@ snapshots:
|
||||||
'@types/react': 19.0.10
|
'@types/react': 19.0.10
|
||||||
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
||||||
|
|
||||||
|
'@radix-ui/react-slider@1.3.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/number': 1.1.1
|
||||||
|
'@radix-ui/primitive': 1.1.2
|
||||||
|
'@radix-ui/react-collection': 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
'@radix-ui/react-use-size': 1.1.1(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
react: 19.0.0
|
||||||
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.0.10
|
||||||
|
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
||||||
|
|
||||||
'@radix-ui/react-slot@1.0.0(react@19.0.0)':
|
'@radix-ui/react-slot@1.0.0(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.26.9
|
'@babel/runtime': 7.26.9
|
||||||
|
@ -5771,6 +5862,13 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.0.10
|
'@types/react': 19.0.10
|
||||||
|
|
||||||
|
'@radix-ui/react-slot@1.2.2(@types/react@19.0.10)(react@19.0.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
|
||||||
|
react: 19.0.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.0.10
|
||||||
|
|
||||||
'@radix-ui/react-tabs@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
'@radix-ui/react-tabs@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.1
|
'@radix-ui/primitive': 1.1.1
|
||||||
|
|
Loading…
Add table
Reference in a new issue