mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-05 13:52:40 +02:00
feat: added attachment support
This commit is contained in:
parent
bb971460fc
commit
c2dcb2045d
62 changed files with 1166 additions and 9012 deletions
|
|
@ -1,24 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { Loader2, PanelRight } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { activeChathatUIAtom, activeChatIdAtom } from "@/atoms/chats/ui.atoms";
|
||||
import { llmPreferencesAtom } from "@/atoms/llm-config/llm-config-query.atoms";
|
||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer";
|
||||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
|
|
@ -34,33 +30,8 @@ export function DashboardClientLayout({
|
|||
const t = useTranslations("dashboard");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchSpaceIdNum = Number(searchSpaceId);
|
||||
const { search_space_id, chat_id } = useParams();
|
||||
const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom);
|
||||
const activeChatId = useAtomValue(activeChatIdAtom);
|
||||
const { search_space_id } = useParams();
|
||||
const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
|
||||
const setActiveChatIdState = useSetAtom(activeChatIdAtom);
|
||||
const [showIndicator, setShowIndicator] = useState(false);
|
||||
|
||||
const { isChatPannelOpen } = chatUIState;
|
||||
|
||||
// Check if we're on the researcher page
|
||||
const isResearcherPage = pathname?.includes("/researcher");
|
||||
|
||||
// Check if we're on the new-chat page (uses separate thread persistence)
|
||||
const isNewChatPage = pathname?.includes("/new-chat");
|
||||
|
||||
// Show indicator when chat becomes active and panel is closed
|
||||
useEffect(() => {
|
||||
if (activeChatId && !isChatPannelOpen) {
|
||||
setShowIndicator(true);
|
||||
// Hide indicator after 5 seconds
|
||||
const timer = setTimeout(() => setShowIndicator(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShowIndicator(false);
|
||||
}
|
||||
}, [activeChatId, isChatPannelOpen]);
|
||||
|
||||
const { data: preferences = {}, isFetching: loading, error } = useAtomValue(llmPreferencesAtom);
|
||||
|
||||
|
|
@ -151,24 +122,7 @@ export function DashboardClientLayout({
|
|||
: "";
|
||||
if (!activeSeacrhSpaceId) return;
|
||||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||
}, [search_space_id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip setting activeChatIdAtom on new-chat page (uses separate thread persistence)
|
||||
if (isNewChatPage) {
|
||||
setActiveChatIdState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeChatId =
|
||||
typeof chat_id === "string"
|
||||
? chat_id
|
||||
: Array.isArray(chat_id) && chat_id.length > 0
|
||||
? chat_id[0]
|
||||
: "";
|
||||
if (!activeChatId) return;
|
||||
setActiveChatIdState(activeChatId);
|
||||
}, [chat_id, search_space_id, isNewChatPage]);
|
||||
}, [search_space_id, setActiveSearchSpaceIdState]);
|
||||
|
||||
// Show loading screen while checking onboarding status (only on first load)
|
||||
if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) {
|
||||
|
|
@ -221,123 +175,20 @@ export function DashboardClientLayout({
|
|||
navMain={translatedNavMain}
|
||||
/>
|
||||
<SidebarInset className="h-full ">
|
||||
<main className="flex h-full">
|
||||
<div className="flex grow flex-col h-full border-r">
|
||||
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
|
||||
<div className="flex items-center justify-between w-full gap-2 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<DashboardBreadcrumb />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
{/* Only show artifacts toggle on researcher page */}
|
||||
{isResearcherPage && (
|
||||
<motion.div
|
||||
className="relative"
|
||||
animate={
|
||||
showIndicator
|
||||
? {
|
||||
scale: [1, 1.05, 1],
|
||||
}
|
||||
: {}
|
||||
}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: showIndicator ? Number.POSITIVE_INFINITY : 0,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChatUIState((prev) => ({
|
||||
...prev,
|
||||
isChatPannelOpen: !isChatPannelOpen,
|
||||
}));
|
||||
setShowIndicator(false);
|
||||
}}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full p-2 transition-all duration-300 relative",
|
||||
showIndicator
|
||||
? "bg-primary/20 hover:bg-primary/30 shadow-lg shadow-primary/25"
|
||||
: "hover:bg-muted",
|
||||
activeChatId && !showIndicator && "hover:bg-primary/10"
|
||||
)}
|
||||
title="Toggle Artifacts Panel"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={
|
||||
showIndicator
|
||||
? {
|
||||
rotate: [0, -10, 10, -10, 0],
|
||||
}
|
||||
: {}
|
||||
}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
repeat: showIndicator ? Number.POSITIVE_INFINITY : 0,
|
||||
repeatDelay: 2,
|
||||
}}
|
||||
>
|
||||
<PanelRight
|
||||
className={cn(
|
||||
"h-4 w-4 transition-colors",
|
||||
showIndicator && "text-primary"
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
|
||||
{/* Pulsing indicator badge */}
|
||||
<AnimatePresence>
|
||||
{showIndicator && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0 }}
|
||||
className="absolute -right-1 -top-1 pointer-events-none"
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-primary shadow-lg" />
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 2.5, 1],
|
||||
opacity: [0.6, 0, 0.6],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="absolute inset-0 h-2.5 w-2.5 rounded-full bg-primary"
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<main className="flex flex-col h-full">
|
||||
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
|
||||
<div className="flex items-center justify-between w-full gap-2 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<DashboardBreadcrumb />
|
||||
</div>
|
||||
</header>
|
||||
<div className="grow flex-1 overflow-auto min-h-[calc(100vh-64px)]">{children}</div>
|
||||
</div>
|
||||
{/* Only render chat panel on researcher page */}
|
||||
{isResearcherPage && <ChatPanelContainer />}
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="grow flex-1 overflow-auto min-h-[calc(100vh-64px)]">{children}</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
type AppendMessage,
|
||||
AssistantRuntimeProvider,
|
||||
type ThreadMessageLike,
|
||||
useExternalStoreRuntime,
|
||||
|
|
@ -11,6 +12,7 @@ import { toast } from "sonner";
|
|||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
|
||||
import {
|
||||
isPodcastGenerating,
|
||||
looksLikePodcastRequest,
|
||||
|
|
@ -59,6 +61,9 @@ export default function NewChatPage() {
|
|||
const [isRunning, setIsRunning] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Create the attachment adapter for file processing
|
||||
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
|
||||
|
||||
// Extract search_space_id from URL params
|
||||
const searchSpaceId = useMemo(() => {
|
||||
const id = params.search_space_id;
|
||||
|
|
@ -99,7 +104,10 @@ export default function NewChatPage() {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("[NewChatPage] Failed to initialize thread:", error);
|
||||
setThreadId(Date.now());
|
||||
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
|
||||
// that will cause 404 errors on subsequent API calls
|
||||
setThreadId(null);
|
||||
toast.error("Failed to initialize chat. Please try again.");
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
|
|
@ -121,18 +129,27 @@ export default function NewChatPage() {
|
|||
|
||||
// Handle new message from user
|
||||
const onNew = useCallback(
|
||||
async (message: ThreadMessageLike) => {
|
||||
async (message: AppendMessage) => {
|
||||
if (!threadId) return;
|
||||
|
||||
// Extract user query text
|
||||
// Extract user query text from content parts
|
||||
let userQuery = "";
|
||||
for (const part of message.content) {
|
||||
if (typeof part === "object" && part.type === "text" && "text" in part) {
|
||||
if (part.type === "text") {
|
||||
userQuery += part.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userQuery.trim()) return;
|
||||
// Extract attachments from message
|
||||
// AppendMessage.attachments contains the processed attachment objects (from adapter.send())
|
||||
const messageAttachments: Array<Record<string, unknown>> = [];
|
||||
if (message.attachments && message.attachments.length > 0) {
|
||||
for (const att of message.attachments) {
|
||||
messageAttachments.push(att as unknown as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
if (!userQuery.trim() && messageAttachments.length === 0) return;
|
||||
|
||||
// Check if podcast is already generating
|
||||
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
|
||||
|
|
@ -239,6 +256,9 @@ export default function NewChatPage() {
|
|||
})
|
||||
.filter((m) => m.content.length > 0);
|
||||
|
||||
// Extract attachment content to send with the request
|
||||
const attachments = extractAttachmentContent(messageAttachments);
|
||||
|
||||
const response = await fetch(`${backendUrl}/api/v1/new_chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -250,6 +270,7 @@ export default function NewChatPage() {
|
|||
user_query: userQuery.trim(),
|
||||
search_space_id: searchSpaceId,
|
||||
messages: messageHistory,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
@ -405,13 +426,29 @@ export default function NewChatPage() {
|
|||
[]
|
||||
);
|
||||
|
||||
// Create external store runtime
|
||||
// Handle editing a message - removes messages after the edited one and sends as new
|
||||
const onEdit = useCallback(
|
||||
async (message: AppendMessage) => {
|
||||
// Find the message being edited by looking at the parentId
|
||||
// The parentId tells us which message's response we're editing
|
||||
// For now, we'll just treat edits like new messages
|
||||
// A more sophisticated implementation would truncate the history
|
||||
await onNew(message);
|
||||
},
|
||||
[onNew]
|
||||
);
|
||||
|
||||
// Create external store runtime with attachment support
|
||||
const runtime = useExternalStoreRuntime({
|
||||
messages,
|
||||
isRunning,
|
||||
onNew,
|
||||
onEdit,
|
||||
convertMessage,
|
||||
onCancel: cancelRun,
|
||||
adapters: {
|
||||
attachments: attachmentAdapter,
|
||||
},
|
||||
});
|
||||
|
||||
// Show loading state
|
||||
|
|
@ -423,6 +460,25 @@ export default function NewChatPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// Show error state if thread initialization failed
|
||||
if (!threadId) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
|
||||
<div className="text-destructive">Failed to initialize chat</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsInitializing(true);
|
||||
initializeThread();
|
||||
}}
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<GeneratePodcastToolUI />
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
import { Suspense } from "react";
|
||||
import PodcastsPageClient from "./podcasts-client";
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
search_space_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PodcastsPage({ params }: PageProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,957 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import {
|
||||
Calendar,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
Podcast as PodcastIcon,
|
||||
Search,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Trash2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deletePodcastMutationAtom } from "@/atoms/podcasts/podcast-mutation.atoms";
|
||||
import { podcastsAtom } from "@/atoms/podcasts/podcast-query.atoms";
|
||||
// UI Components
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import type { Podcast } from "@/contracts/types/podcast.types";
|
||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
||||
|
||||
interface PodcastsPageClientProps {
|
||||
searchSpaceId: string;
|
||||
}
|
||||
|
||||
const pageVariants: Variants = {
|
||||
initial: { opacity: 0 },
|
||||
enter: {
|
||||
opacity: 1,
|
||||
transition: { duration: 0.4, ease: "easeInOut", staggerChildren: 0.1 },
|
||||
},
|
||||
exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } },
|
||||
};
|
||||
|
||||
const podcastCardVariants: Variants = {
|
||||
initial: { scale: 0.95, y: 20, opacity: 0 },
|
||||
animate: {
|
||||
scale: 1,
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: { type: "spring", stiffness: 300, damping: 25 },
|
||||
},
|
||||
exit: { scale: 0.95, y: -20, opacity: 0 },
|
||||
hover: { y: -5, scale: 1.02, transition: { duration: 0.2 } },
|
||||
};
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
|
||||
export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) {
|
||||
const [filteredPodcasts, setFilteredPodcasts] = useState<Podcast[]>([]);
|
||||
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);
|
||||
|
||||
// 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);
|
||||
const [{ isPending: isDeletingPodcast, mutateAsync: deletePodcast, error: deleteError }] =
|
||||
useAtom(deletePodcastMutationAtom);
|
||||
const {
|
||||
data: podcasts,
|
||||
isLoading: isFetchingPodcasts,
|
||||
error: fetchError,
|
||||
} = useAtomValue(podcastsAtom);
|
||||
|
||||
// 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";
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetchingPodcasts) return;
|
||||
|
||||
if (fetchError) {
|
||||
console.error("Error fetching podcasts:", fetchError);
|
||||
setFilteredPodcasts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!podcasts) {
|
||||
setFilteredPodcasts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setFilteredPodcasts(podcasts);
|
||||
}, []);
|
||||
|
||||
// Filter and sort podcasts based on search query and sort order
|
||||
useEffect(() => {
|
||||
if (!podcasts) return;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// To close player
|
||||
const closePlayer = () => {
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause();
|
||||
}
|
||||
setIsPlaying(false);
|
||||
setAudioSrc(undefined);
|
||||
setCurrentTime(0);
|
||||
setCurrentPodcast(null);
|
||||
};
|
||||
|
||||
// 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];
|
||||
|
||||
// Set volume
|
||||
audioRef.current.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
|
||||
// Handle mute state based on volume
|
||||
if (newVolume === 0) {
|
||||
audioRef.current.muted = true;
|
||||
setIsMuted(true);
|
||||
} else {
|
||||
audioRef.current.muted = false;
|
||||
setIsMuted(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle mute
|
||||
const toggleMute = () => {
|
||||
if (audioRef.current) {
|
||||
const newMutedState = !isMuted;
|
||||
audioRef.current.muted = newMutedState;
|
||||
setIsMuted(newMutedState);
|
||||
|
||||
// If unmuting, restore previous volume if it was 0
|
||||
if (!newMutedState && volume === 0) {
|
||||
const restoredVolume = 0.5;
|
||||
audioRef.current.volume = restoredVolume;
|
||||
setVolume(restoredVolume);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Prevent multiple simultaneous loading requests
|
||||
if (isAudioLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Reset player state and show loading
|
||||
setCurrentPodcast(podcast);
|
||||
setAudioSrc(undefined);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsPlaying(false);
|
||||
setIsAudioLoading(true);
|
||||
|
||||
// Revoke previous object URL if exists (only after we've started the new request)
|
||||
if (currentObjectUrlRef.current) {
|
||||
URL.revokeObjectURL(currentObjectUrlRef.current);
|
||||
currentObjectUrlRef.current = null;
|
||||
}
|
||||
|
||||
// Use AbortController to handle timeout or cancellation
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
try {
|
||||
const response = await podcastsApiService.loadPodcast({
|
||||
request: { id: podcast.id },
|
||||
controller,
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(response);
|
||||
currentObjectUrlRef.current = objectUrl;
|
||||
|
||||
// Set audio source
|
||||
setAudioSrc(objectUrl);
|
||||
|
||||
// Wait for the audio to be ready before playing
|
||||
// We'll handle actual playback in the onLoadedData event instead of here
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new Error("Request timed out. Please try again.");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching or playing podcast:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to load podcast audio.");
|
||||
// Reset state on error
|
||||
setCurrentPodcast(null);
|
||||
setAudioSrc(undefined);
|
||||
} finally {
|
||||
setIsAudioLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle podcast deletion
|
||||
const handleDeletePodcast = async () => {
|
||||
if (!podcastToDelete) return;
|
||||
|
||||
try {
|
||||
await deletePodcast({ id: podcastToDelete.id });
|
||||
|
||||
// Close dialog
|
||||
setDeleteDialogOpen(false);
|
||||
setPodcastToDelete(null);
|
||||
|
||||
// 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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting podcast:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to delete podcast");
|
||||
}
|
||||
};
|
||||
|
||||
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 */}
|
||||
{isFetchingPodcasts && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{fetchError && !isFetchingPodcasts && (
|
||||
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
|
||||
<h3 className="font-medium">Error loading podcasts</h3>
|
||||
<p className="text-sm">{fetchError.message ?? "Failed to load podcasts"}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFetchingPodcasts && !fetchError && filteredPodcasts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
|
||||
<PodcastIcon 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 */}
|
||||
{!isFetchingPodcasts && !fetchError && filteredPodcasts.length > 0 && (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="enter"
|
||||
exit="exit"
|
||||
>
|
||||
{filteredPodcasts.map((podcast, index) => (
|
||||
<MotionCard
|
||||
key={podcast.id}
|
||||
variants={podcastCardVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
whileHover="hover"
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className={`
|
||||
bg-card/60 dark:bg-card/40 backdrop-blur-lg rounded-xl p-4
|
||||
shadow-md hover:shadow-xl transition-all duration-300
|
||||
border-border overflow-hidden cursor-pointer
|
||||
${currentPodcast?.id === podcast.id ? "ring-2 ring-primary ring-offset-2 ring-offset-background" : ""}
|
||||
`}
|
||||
layout
|
||||
onClick={() => playPodcast(podcast)}
|
||||
>
|
||||
<div className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden">
|
||||
{/* Podcast image with gradient overlay */}
|
||||
<Image
|
||||
src={PODCAST_IMAGE_URL}
|
||||
alt="Podcast illustration"
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 brightness-[0.85] contrast-[1.1]"
|
||||
loading="lazy"
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
|
||||
{/* Better overlay with gradient for improved text legibility */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-black/10 transition-opacity duration-300"></div>
|
||||
|
||||
{/* Loading indicator with improved animation */}
|
||||
{currentPodcast?.id === podcast.id && isAudioLoading && (
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-md z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-3"
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", damping: 20 }}
|
||||
>
|
||||
<div className="h-14 w-14 rounded-full border-4 border-primary/30 border-t-primary animate-spin"></div>
|
||||
<p className="text-sm text-foreground font-medium">Loading podcast...</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Play button with animations */}
|
||||
{!(currentPodcast?.id === podcast.id && (isPlaying || isAudioLoading)) && (
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-16 w-16 rounded-full
|
||||
bg-background/80 hover:bg-background/95 backdrop-blur-md
|
||||
transition-all duration-200 shadow-xl border-0
|
||||
flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPodcast(podcast);
|
||||
}}
|
||||
disabled={isAudioLoading}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10,
|
||||
}}
|
||||
className="text-primary w-10 h-10 flex items-center justify-center"
|
||||
>
|
||||
<Play className="h-8 w-8 ml-1" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Pause button with animations */}
|
||||
{currentPodcast?.id === podcast.id && isPlaying && !isAudioLoading && (
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-16 w-16 rounded-full
|
||||
bg-background/80 hover:bg-background/95 backdrop-blur-md
|
||||
transition-all duration-200 shadow-xl border-0
|
||||
flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlayPause();
|
||||
}}
|
||||
disabled={isAudioLoading}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10,
|
||||
}}
|
||||
className="text-primary w-10 h-10 flex items-center justify-center"
|
||||
>
|
||||
<Pause className="h-8 w-8" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Now playing indicator */}
|
||||
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
||||
<div className="absolute top-2 left-2 bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full z-10 flex items-center gap-1.5">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-foreground opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary-foreground"></span>
|
||||
</span>
|
||||
Now Playing
|
||||
</div>
|
||||
)}
|
||||
</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 && (
|
||||
<motion.div
|
||||
className="mb-3 px-1"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-1.5 bg-muted rounded-full cursor-pointer group relative overflow-hidden"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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]);
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-primary rounded-full relative"
|
||||
style={{
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
}}
|
||||
transition={{ ease: "linear" }}
|
||||
>
|
||||
<motion.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
|
||||
group-hover:scale-100 transition-transform"
|
||||
whileHover={{ scale: 1.5 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</Button>
|
||||
<div className="flex justify-between mt-1.5 text-xs text-muted-foreground">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
||||
<motion.div
|
||||
className="flex items-center justify-between px-2 mt-1"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</motion.div>
|
||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</motion.div>
|
||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</motion.div>
|
||||
</motion.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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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={(e) => {
|
||||
e.stopPropagation();
|
||||
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>
|
||||
))}
|
||||
</motion.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 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-sm 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">
|
||||
<motion.div
|
||||
className="w-12 h-12 bg-primary/20 rounded-md flex items-center justify-center"
|
||||
animate={{ scale: isPlaying ? [1, 1.05, 1] : 1 }}
|
||||
transition={{
|
||||
repeat: isPlaying ? Infinity : 0,
|
||||
duration: 2,
|
||||
}}
|
||||
>
|
||||
<PodcastIcon className="h-6 w-6 text-primary" />
|
||||
</motion.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 relative">
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
step={0.1}
|
||||
onValueChange={handleSeek}
|
||||
className="relative z-10"
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute left-0 top-1/2 h-2 bg-primary/25 rounded-full -translate-y-1/2"
|
||||
style={{
|
||||
width: `${(currentTime / (duration || 100)) * 100}%`,
|
||||
}}
|
||||
transition={{ ease: "linear" }}
|
||||
/>
|
||||
</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">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button variant="ghost" size="icon" onClick={skipBackward} className="h-8 w-8">
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<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>
|
||||
</motion.div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button variant="ghost" size="icon" onClick={skipForward} className="h-8 w-8">
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2 ml-4 w-32">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleMute}
|
||||
className={`h-8 w-8 ${isMuted ? "text-muted-foreground" : "text-primary"}`}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative w-full">
|
||||
<Slider
|
||||
value={[isMuted ? 0 : volume]}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onValueChange={handleVolumeChange}
|
||||
className="w-full"
|
||||
disabled={isMuted}
|
||||
/>
|
||||
<motion.div
|
||||
className={`absolute left-0 bottom-0 h-1 bg-primary/30 rounded-full ${isMuted ? "opacity-50" : ""}`}
|
||||
initial={false}
|
||||
animate={{ width: `${(isMuted ? 0 : volume) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={closePlayer}
|
||||
className="h-10 w-10 rounded-full"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</motion.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={isDeletingPodcast}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeletePodcast}
|
||||
disabled={isDeletingPodcast}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDeletingPodcast ? (
|
||||
<>
|
||||
<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}
|
||||
preload="auto"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleMetadataLoaded}
|
||||
onLoadedData={() => {
|
||||
// Only auto-play when audio is fully loaded
|
||||
if (audioRef.current && currentPodcast && audioSrc) {
|
||||
// Small delay to ensure browser is ready to play
|
||||
setTimeout(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error playing audio:", error);
|
||||
// Don't show error if it's just the user navigating away
|
||||
if (error.name !== "AbortError") {
|
||||
toast.error("Failed to play audio.");
|
||||
}
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
onError={(e) => {
|
||||
console.error("Audio error:", e);
|
||||
if (audioRef.current?.error) {
|
||||
// Log the specific error code for debugging
|
||||
console.error("Audio error code:", audioRef.current.error.code);
|
||||
|
||||
// Don't show error message for aborted loads
|
||||
if (audioRef.current.error.code !== audioRef.current.error.MEDIA_ERR_ABORTED) {
|
||||
toast.error("Error playing audio. Please try again.");
|
||||
}
|
||||
}
|
||||
// Reset playing state on error
|
||||
setIsPlaying(false);
|
||||
}}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { type CreateMessage, type Message, useChat } from "@ai-sdk/react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { createChatMutationAtom, updateChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
|
||||
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
||||
import { activeChatIdAtom } from "@/atoms/chats/ui.atoms";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import ChatInterface from "@/components/chat/ChatInterface";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { useChatState } from "@/hooks/use-chat";
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
export default function ResearcherPage() {
|
||||
const { search_space_id } = useParams();
|
||||
const router = useRouter();
|
||||
const hasSetInitialConnectors = useRef(false);
|
||||
const hasInitiatedResponse = useRef<string | null>(null);
|
||||
const activeChatId = useAtomValue(activeChatIdAtom);
|
||||
const { data: activeChatState, isFetching: isChatLoading } = useAtomValue(activeChatAtom);
|
||||
const { mutateAsync: createChat } = useAtomValue(createChatMutationAtom);
|
||||
const { mutateAsync: updateChat } = useAtomValue(updateChatMutationAtom);
|
||||
const isNewChat = !activeChatId;
|
||||
|
||||
// Reset the flag when chat ID changes (but not hasInitiatedResponse - we need to remember if we already initiated)
|
||||
useEffect(() => {
|
||||
hasSetInitialConnectors.current = false;
|
||||
}, [activeChatId]);
|
||||
|
||||
const {
|
||||
token,
|
||||
researchMode,
|
||||
selectedConnectors,
|
||||
setSelectedConnectors,
|
||||
selectedDocuments,
|
||||
setSelectedDocuments,
|
||||
topK,
|
||||
setTopK,
|
||||
} = useChatState({
|
||||
search_space_id: search_space_id as string,
|
||||
chat_id: activeChatId ?? undefined,
|
||||
});
|
||||
|
||||
// Fetch all available sources (document types + live search connectors)
|
||||
// Use the documentTypeCountsAtom for fetching document types
|
||||
const [documentTypeCountsQuery] = useAtom(documentTypeCountsAtom);
|
||||
const { data: documentTypeCountsData } = documentTypeCountsQuery;
|
||||
|
||||
// Transform the response into the expected format
|
||||
const documentTypes = useMemo(() => {
|
||||
if (!documentTypeCountsData) return [];
|
||||
return Object.entries(documentTypeCountsData).map(([type, count]) => ({
|
||||
type,
|
||||
count,
|
||||
}));
|
||||
}, [documentTypeCountsData]);
|
||||
|
||||
const { connectors: searchConnectors } = useSearchSourceConnectors(
|
||||
false,
|
||||
Number(search_space_id)
|
||||
);
|
||||
|
||||
// Filter for non-indexable connectors (live search)
|
||||
const liveSearchConnectors = useMemo(
|
||||
() => searchConnectors.filter((connector) => !connector.is_indexable),
|
||||
[searchConnectors]
|
||||
);
|
||||
|
||||
// Memoize document IDs to prevent infinite re-renders
|
||||
const documentIds = useMemo(() => {
|
||||
return selectedDocuments.map((doc) => doc.id);
|
||||
}, [selectedDocuments]);
|
||||
|
||||
// Memoize connector types to prevent infinite re-renders
|
||||
const connectorTypes = useMemo(() => {
|
||||
return selectedConnectors;
|
||||
}, [selectedConnectors]);
|
||||
|
||||
// Unified localStorage management for chat state
|
||||
interface ChatState {
|
||||
selectedDocuments: Document[];
|
||||
selectedConnectors: string[];
|
||||
researchMode: "QNA"; // Always QNA mode
|
||||
topK: number;
|
||||
}
|
||||
|
||||
const getChatStateStorageKey = (searchSpaceId: string, chatId: string) =>
|
||||
`surfsense_chat_state_${searchSpaceId}_${chatId}`;
|
||||
|
||||
const storeChatState = (searchSpaceId: string, chatId: string, state: ChatState) => {
|
||||
const key = getChatStateStorageKey(searchSpaceId, chatId);
|
||||
localStorage.setItem(key, JSON.stringify(state));
|
||||
};
|
||||
|
||||
const restoreChatState = (searchSpaceId: string, chatId: string): ChatState | null => {
|
||||
const key = getChatStateStorageKey(searchSpaceId, chatId);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
localStorage.removeItem(key); // Clean up after restoration
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch (error) {
|
||||
console.error("Error parsing stored chat state:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handler = useChat({
|
||||
api: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chat`,
|
||||
streamProtocol: "data",
|
||||
initialMessages: [],
|
||||
headers: {
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
},
|
||||
body: {
|
||||
data: {
|
||||
search_space_id: search_space_id,
|
||||
selected_connectors: connectorTypes,
|
||||
research_mode: researchMode,
|
||||
document_ids_to_add_in_context: documentIds,
|
||||
top_k: topK,
|
||||
},
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Chat error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const customHandlerAppend = async (
|
||||
message: Message | CreateMessage,
|
||||
chatRequestOptions?: { data?: any }
|
||||
) => {
|
||||
// Use the first message content as the chat title (truncated to 100 chars)
|
||||
const messageContent = typeof message.content === "string" ? message.content : "";
|
||||
const chatTitle = messageContent.slice(0, 100) || "Untitled Chat";
|
||||
|
||||
const newChat = await createChat({
|
||||
type: researchMode,
|
||||
title: chatTitle,
|
||||
initial_connectors: selectedConnectors,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: message.content,
|
||||
},
|
||||
],
|
||||
search_space_id: Number(search_space_id),
|
||||
});
|
||||
if (newChat) {
|
||||
// Store chat state before navigation
|
||||
storeChatState(search_space_id as string, String(newChat.id), {
|
||||
selectedDocuments,
|
||||
selectedConnectors,
|
||||
researchMode,
|
||||
topK,
|
||||
});
|
||||
router.replace(`/dashboard/${search_space_id}/researcher/${newChat.id}`);
|
||||
}
|
||||
return String(newChat.id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (token && !isNewChat && activeChatId) {
|
||||
const chatData = activeChatState?.chatDetails;
|
||||
if (!chatData) return;
|
||||
|
||||
// Update configuration from chat data
|
||||
// researchMode is always "QNA", no need to set from chat data
|
||||
|
||||
if (chatData.initial_connectors && Array.isArray(chatData.initial_connectors)) {
|
||||
setSelectedConnectors(chatData.initial_connectors);
|
||||
}
|
||||
|
||||
// Load existing messages
|
||||
if (chatData.messages && Array.isArray(chatData.messages)) {
|
||||
if (chatData.messages.length === 1 && chatData.messages[0].role === "user") {
|
||||
// Single user message - append to trigger LLM response
|
||||
// Only if we haven't already initiated for this chat and handler doesn't have messages yet
|
||||
if (hasInitiatedResponse.current !== activeChatId && handler.messages.length === 0) {
|
||||
hasInitiatedResponse.current = activeChatId;
|
||||
handler.append({
|
||||
role: "user",
|
||||
content: chatData.messages[0].content,
|
||||
});
|
||||
}
|
||||
} else if (chatData.messages.length > 1) {
|
||||
// Multiple messages - set them all
|
||||
handler.setMessages(chatData.messages);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [token, isNewChat, activeChatId, isChatLoading]);
|
||||
|
||||
// Restore chat state from localStorage on page load
|
||||
useEffect(() => {
|
||||
if (activeChatId && search_space_id) {
|
||||
const restoredState = restoreChatState(search_space_id as string, activeChatId);
|
||||
if (restoredState) {
|
||||
setSelectedDocuments(restoredState.selectedDocuments);
|
||||
setSelectedConnectors(restoredState.selectedConnectors);
|
||||
setTopK(restoredState.topK);
|
||||
// researchMode is always "QNA", no need to restore
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeChatId,
|
||||
isChatLoading,
|
||||
search_space_id,
|
||||
setSelectedDocuments,
|
||||
setSelectedConnectors,
|
||||
setTopK,
|
||||
]);
|
||||
|
||||
// Set all sources as default for new chats (only once on initial mount)
|
||||
useEffect(() => {
|
||||
if (
|
||||
isNewChat &&
|
||||
!hasSetInitialConnectors.current &&
|
||||
selectedConnectors.length === 0 &&
|
||||
documentTypes.length > 0
|
||||
) {
|
||||
// Combine all document types and live search connectors
|
||||
const allSourceTypes = [
|
||||
...documentTypes.map((dt) => dt.type),
|
||||
...liveSearchConnectors.map((c) => c.connector_type),
|
||||
];
|
||||
|
||||
if (allSourceTypes.length > 0) {
|
||||
setSelectedConnectors(allSourceTypes);
|
||||
hasSetInitialConnectors.current = true;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isNewChat,
|
||||
documentTypes,
|
||||
liveSearchConnectors,
|
||||
selectedConnectors.length,
|
||||
setSelectedConnectors,
|
||||
]);
|
||||
|
||||
// Auto-update chat when messages change (only for existing chats)
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isNewChat &&
|
||||
activeChatId &&
|
||||
handler.status === "ready" &&
|
||||
handler.messages.length > 0 &&
|
||||
handler.messages[handler.messages.length - 1]?.role === "assistant"
|
||||
) {
|
||||
const userMessages = handler.messages.filter((msg) => msg.role === "user");
|
||||
if (userMessages.length === 0) return;
|
||||
const title = userMessages[0].content;
|
||||
|
||||
updateChat({
|
||||
type: researchMode,
|
||||
title: title,
|
||||
initial_connectors: selectedConnectors,
|
||||
messages: handler.messages,
|
||||
search_space_id: Number(search_space_id),
|
||||
id: Number(activeChatId),
|
||||
});
|
||||
}
|
||||
}, [handler.messages, handler.status, activeChatId, isNewChat, isChatLoading]);
|
||||
|
||||
if (isChatLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatInterface
|
||||
handler={{
|
||||
...handler,
|
||||
append: isNewChat ? customHandlerAppend : handler.append,
|
||||
}}
|
||||
onDocumentSelectionChange={setSelectedDocuments}
|
||||
selectedDocuments={selectedDocuments}
|
||||
onConnectorSelectionChange={setSelectedConnectors}
|
||||
selectedConnectors={selectedConnectors}
|
||||
topK={topK}
|
||||
onTopKChange={setTopK}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -244,7 +244,7 @@ const DashboardPage = () => {
|
|||
/>
|
||||
<div className="flex flex-col h-full justify-between overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
|
||||
<div className="relative h-32 w-full overflow-hidden">
|
||||
<Link href={`/dashboard/${space.id}/researcher`} key={space.id}>
|
||||
<Link href={`/dashboard/${space.id}/new-chat`} key={space.id}>
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
|
||||
alt={space.name}
|
||||
|
|
@ -289,7 +289,7 @@ const DashboardPage = () => {
|
|||
</div>
|
||||
<Link
|
||||
className="flex flex-1 flex-col p-4 cursor-pointer"
|
||||
href={`/dashboard/${space.id}/researcher`}
|
||||
href={`/dashboard/${space.id}/new-chat`}
|
||||
key={space.id}
|
||||
>
|
||||
<div className="flex flex-1 flex-col justify-between p-1">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue