Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions frontend/app/(dashboard)/youtube/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import VideoLibrary from '@/features/youtube/components/VideoLibrary';

const YouTubePage = () => {
return (
<div className='min-h-screen bg-[#13131b] px-4 py-8'>
<div className='mx-auto max-w-5xl'>
<div className='mb-6 flex items-center gap-3'>
<Link
href='/chat'
aria-label='Back to chat'
className='flex h-8 w-8 items-center justify-center rounded-md text-[#6b6b8a] transition-colors hover:bg-[#1e1e2e] hover:text-[#e4e1ed]'
>
<ArrowLeft className='h-5 w-5' />
</Link>
<h1 className='text-xl font-semibold text-[#e4e1ed]'>
YouTube Transcripts
</h1>
</div>
<VideoLibrary />
</div>
</div>
);
};

export default YouTubePage;
9 changes: 9 additions & 0 deletions frontend/features/chat/components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Bookmark,
Volume2,
Headphones,
Video,
Menu,
X,
} from 'lucide-react';
Expand Down Expand Up @@ -216,6 +217,14 @@ const ChatWindow = () => {
>
<Headphones className='h-4 w-4' />
</Link>
<Link
href='/youtube'
className='inline-flex h-9 w-9 items-center justify-center rounded-md text-[#6b6b8a] transition-colors hover:bg-[#2a2a3d] hover:text-[#e4e1ed]'
aria-label='YouTube Transcripts'
title='YouTube Transcripts'
>
<Video className='h-4 w-4' />
</Link>
<Button
onClick={resetConversation}
size='icon'
Expand Down
48 changes: 48 additions & 0 deletions frontend/features/youtube/components/VideoLibrary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client';

import { useVideoTranscripts } from '@/features/youtube/hooks/useYoutube';
import VideoTranscriptCard from '@/features/youtube/components/VideoTranscriptCard';

const SkeletonCard = () => (
<div className='flex animate-pulse flex-col gap-3 rounded-lg border border-[#2a2a3d] bg-[#1a1a2e] p-4'>
<div className='h-4 w-3/4 rounded bg-[#2a2a3d]' />
<div className='h-3 w-1/3 rounded bg-[#2a2a3d]' />
<div className='h-16 rounded bg-[#2a2a3d]' />
<div className='h-3 w-1/4 rounded bg-[#2a2a3d]' />
</div>
);

const VideoLibrary = () => {
const { data: transcripts, isLoading } = useVideoTranscripts();

if (isLoading) {
return (
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
{[1, 2, 3].map((i) => (
<SkeletonCard key={i} />
))}
</div>
);
}

if (!transcripts || transcripts.length === 0) {
return (
<div className='flex flex-col items-center justify-center py-24 text-center'>
<p className='text-sm text-[#6b6b8a]'>No saved transcripts yet.</p>
<p className='mt-1 text-xs text-[#6b6b8a]'>
Share a YouTube link with AXON to summarize and save it here.
</p>
</div>
);
}

return (
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
{transcripts.map((t) => (
<VideoTranscriptCard key={t.id} transcript={t} />
))}
</div>
);
};

export default VideoLibrary;
178 changes: 178 additions & 0 deletions frontend/features/youtube/components/VideoTranscriptCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
'use client';

import { useState } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { Trash2, Clock, ChevronDown, ChevronUp, Video } from 'lucide-react';
import {
DialogRoot,
DialogPopup,
DialogTitle,
DialogDescription,
DialogClose,
} from '@/components/ui/dialog';
import { useDeleteTranscript } from '@/features/youtube/hooks/useYoutube';
import type { VideoTranscriptOut } from '@/features/youtube/types';

interface VideoTranscriptCardProps {
transcript: VideoTranscriptOut;
}

function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0)
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}

const VideoTranscriptCard = ({ transcript }: VideoTranscriptCardProps) => {
const [deleteOpen, setDeleteOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const { mutate: deleteTranscript, isPending: isDeleting } =
useDeleteTranscript();

const displayTitle = transcript.title ?? transcript.video_id;

function confirmDelete() {
deleteTranscript(transcript.id, { onSuccess: () => setDeleteOpen(false) });
}

const relativeTime = formatDistanceToNow(new Date(transcript.created_at), {
addSuffix: true,
});

return (
<div className='flex flex-col gap-3 rounded-lg border border-[#2a2a3d] bg-[#1a1a2e] p-4'>
{/* Header */}
<div className='flex items-start gap-2'>
<a
href={transcript.youtube_url}
target='_blank'
rel='noopener noreferrer'
className='mt-0.5 shrink-0 text-[#6b6b8a] transition-opacity hover:opacity-75'
aria-label='Open on YouTube'
>
<Video className='h-4 w-4' />
</a>
<a
href={transcript.youtube_url}
target='_blank'
rel='noopener noreferrer'
className='min-w-0 flex-1 text-sm font-medium text-[#e4e1ed] hover:underline'
title={displayTitle}
>
<span className='line-clamp-2'>{displayTitle}</span>
</a>
<button
type='button'
onClick={() => setDeleteOpen(true)}
disabled={isDeleting}
aria-label='Delete transcript'
className='flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-[#6b6b8a] transition-colors hover:bg-[#2a2a3d] hover:text-[#ff6b6b] disabled:cursor-not-allowed disabled:opacity-50'
>
<Trash2 className='h-4 w-4' />
</button>
</div>

{/* Metadata */}
{(transcript.channel || transcript.duration_s != null) && (
<div className='flex items-center gap-3 text-xs text-[#6b6b8a]'>
{transcript.channel && (
<span className='truncate'>{transcript.channel}</span>
)}
{transcript.duration_s != null && (
<span className='flex shrink-0 items-center gap-1'>
<Clock className='h-3 w-3' />
{formatDuration(transcript.duration_s)}
</span>
)}
</div>
)}

{/* Summary */}
<div>
<p
className={`text-xs leading-relaxed text-[#9b9bb8] ${!expanded ? 'line-clamp-3' : ''}`}
>
{transcript.summary}
</p>
{transcript.summary.length > 160 && (
<button
type='button'
onClick={() => setExpanded(!expanded)}
className='mt-1 flex items-center gap-0.5 text-xs text-[#6b6b8a] hover:text-[#c0c1ff]'
>
{expanded ? (
<>
Show less <ChevronUp className='h-3 w-3' />
</>
) : (
<>
Show more <ChevronDown className='h-3 w-3' />
</>
)}
</button>
)}
</div>

{/* Key Points */}
{transcript.key_points.length > 0 && (
<ul className='flex flex-col gap-1'>
{transcript.key_points
.slice(0, expanded ? undefined : 3)
.map((point, i) => (
<li
key={i}
className='flex items-start gap-1.5 text-xs text-[#9b9bb8]'
>
<span className='mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-[#494bd6]' />
{point}
</li>
))}
{!expanded && transcript.key_points.length > 3 && (
<li className='text-xs text-[#6b6b8a]'>
+{transcript.key_points.length - 3} more
</li>
)}
</ul>
)}

{/* Footer */}
<p className='text-xs text-[#6b6b8a]'>{relativeTime}</p>

{/* Delete confirmation dialog */}
<DialogRoot open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogPopup>
<DialogTitle>Delete transcript?</DialogTitle>
<DialogDescription>
This will permanently delete &ldquo;{displayTitle}&rdquo; and its
cross-indexed memory entry. This action cannot be undone.
</DialogDescription>
<div className='mt-5 flex justify-end gap-2'>
<DialogClose
render={
<button
type='button'
className='cursor-pointer rounded-lg px-4 py-2 text-sm text-[#9b9bb8] transition-colors hover:bg-[#1e1e2e] hover:text-[#e4e1ed]'
/>
}
>
Cancel
</DialogClose>
<button
type='button'
disabled={isDeleting}
onClick={confirmDelete}
className='cursor-pointer rounded-lg bg-[#ff4444]/10 px-4 py-2 text-sm text-[#ff6b6b] transition-colors hover:bg-[#ff4444]/20 disabled:cursor-not-allowed disabled:opacity-50'
>
{isDeleting ? 'Deleting…' : 'Delete'}
</button>
</div>
</DialogPopup>
</DialogRoot>
</div>
);
};

export default VideoTranscriptCard;
44 changes: 44 additions & 0 deletions frontend/features/youtube/hooks/useYoutube.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
'use client';

import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { deleteVideoTranscript, listVideoTranscripts } from '@/lib/api';
import { createClient } from '@/lib/supabase/client';
import type { VideoTranscriptOut } from '@/features/youtube/types';

// Broad prefix for mutation invalidation — matches all user variants.
export const VIDEO_TRANSCRIPTS_KEY = ['video-transcripts'] as const;

function videoTranscriptsKey(userId: string) {
return ['video-transcripts', userId] as const;
}

function useUserId(): string {
const [userId, setUserId] = useState('');
useEffect(() => {
createClient()
.auth.getSession()
.then(({ data }) => setUserId(data.session?.user.id ?? ''));
}, []);
return userId;
}

export function useVideoTranscripts() {
const userId = useUserId();
return useQuery<VideoTranscriptOut[]>({
queryKey: videoTranscriptsKey(userId),
queryFn: listVideoTranscripts,
enabled: !!userId,
staleTime: 60_000,
});
}

export function useDeleteTranscript() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: deleteVideoTranscript,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: VIDEO_TRANSCRIPTS_KEY });
},
});
}
11 changes: 11 additions & 0 deletions frontend/features/youtube/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface VideoTranscriptOut {
id: string;
youtube_url: string;
video_id: string;
title: string | null;
channel: string | null;
duration_s: number | null;
summary: string;
key_points: string[];
created_at: string;
}
16 changes: 16 additions & 0 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
SSEEvent,
} from '@/features/chat/types';
import type { AudioEntryOut } from '@/features/audio/types';
import type { VideoTranscriptOut } from '@/features/youtube/types';

const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';

Expand Down Expand Up @@ -260,3 +261,18 @@ export function deleteAudioEntry(id: string): Promise<void> {
method: 'DELETE',
});
}

export function listVideoTranscripts(): Promise<VideoTranscriptOut[]> {
return apiFetch<VideoTranscriptOut[]>('/api/v1/youtube');
}

const _deleteTranscriptSchema = z.object({
id: z.string().uuid(),
});

export function deleteVideoTranscript(id: string): Promise<void> {
const { id: validId } = _deleteTranscriptSchema.parse({ id });
return apiFetchVoid(`/api/v1/youtube/${encodeURIComponent(validId)}`, {
method: 'DELETE',
});
}