diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fd709c9..c29b8ba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,8 +15,9 @@ }, "devDependencies": { "@eslint/js": "^9.19.0", - "@types/react": "^19.0.8", + "@types/react": "^19.0.10", "@types/react-dom": "^19.0.3", + "@types/react-icons": "^3.0.0", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.19.0", "eslint-plugin-react-hooks": "^5.0.0", @@ -1564,11 +1565,10 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.0.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", - "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "dev": true, - "license": "MIT", "dependencies": { "csstype": "^3.0.2" } @@ -1583,6 +1583,16 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-icons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz", + "integrity": "sha512-Vefs6LkLqF61vfV7AiAqls+vpR94q67gunhMueDznG+msAkrYgRxl7gYjNem/kZ+as2l2mNChmF1jRZzzQQtMg==", + "deprecated": "This is a stub types definition. react-icons provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "react-icons": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.24.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.0.tgz", @@ -3254,6 +3264,15 @@ "react": "^19.0.0" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "dev": true, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index e9b6929..221ec31 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,8 +17,9 @@ }, "devDependencies": { "@eslint/js": "^9.19.0", - "@types/react": "^19.0.8", + "@types/react": "^19.0.10", "@types/react-dom": "^19.0.3", + "@types/react-icons": "^3.0.0", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.19.0", "eslint-plugin-react-hooks": "^5.0.0", diff --git a/frontend/public/assets/placeholder.jpg b/frontend/public/assets/placeholder.jpg new file mode 100644 index 0000000..8e9c222 Binary files /dev/null and b/frontend/public/assets/placeholder.jpg differ diff --git a/frontend/src/components/AddSongPanel.tsx b/frontend/src/components/AddSongPanel.tsx index 9b53bcb..ef00105 100644 --- a/frontend/src/components/AddSongPanel.tsx +++ b/frontend/src/components/AddSongPanel.tsx @@ -1,11 +1,14 @@ -import React, { useState } from 'react'; - +import React, { useState, KeyboardEvent, ChangeEvent } from 'react'; +import { FaSearch } from 'react-icons/fa'; +import InvidiousSearchModal from './InvidiousSearchModal'; +import { USE_INVIDIOUS } from '../config'; interface AddSongPanelProps { onAddURL: (url: string) => void; } const AddSongPanel: React.FC = ({ onAddURL }) => { const [url, setUrl] = useState(''); + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); const handleAddURL = () => { onAddURL(url); @@ -15,13 +18,22 @@ const AddSongPanel: React.FC = ({ onAddURL }) => { return (
+ {USE_INVIDIOUS && ( + + )} + setUrl(e.target.value)} + onChange={(e: ChangeEvent) => setUrl(e.target.value)} placeholder="Add any URL..." className="p-2 rounded-lg border-2 border-violet-500 flex-grow" - onKeyDown={(e) => { + onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') { handleAddURL(); } @@ -35,6 +47,15 @@ const AddSongPanel: React.FC = ({ onAddURL }) => { Add
+ + setIsSearchModalOpen(false)} + onSelectVideo={(videoUrl) => { + onAddURL(videoUrl); + setIsSearchModalOpen(false); + }} + />
); }; diff --git a/frontend/src/components/InvidiousSearchModal.tsx b/frontend/src/components/InvidiousSearchModal.tsx new file mode 100644 index 0000000..edb3514 --- /dev/null +++ b/frontend/src/components/InvidiousSearchModal.tsx @@ -0,0 +1,135 @@ +import React, { useState, KeyboardEvent } from 'react'; +import { FaSearch, FaSpinner, FaTimes } from 'react-icons/fa'; +import { getInvidiousSearchURL, INVIDIOUS_BASE_URL } from '../config'; + +interface InvidiousVideoThumbnail { + quality: string; + url: string; + width: number; + height: number; +} + +interface InvidiousResult { + type: string; + title: string; + videoId: string; + author: string; + videoThumbnails?: InvidiousVideoThumbnail[]; +} + +interface InvidiousSearchModalProps { + isOpen: boolean; + onClose: () => void; + onSelectVideo: (url: string) => void; +} + +const ResultCell: React.FC<{ result: InvidiousResult, onClick: () => void }> = ({ result, onClick, ...props }) => { + const thumbnailUrl = (result: InvidiousResult) => { + if (!result.videoThumbnails) return '/assets/placeholder.jpg'; + + const thumbnail = result.videoThumbnails.find(t => t.quality === 'medium'); + return thumbnail ? `${INVIDIOUS_BASE_URL}${thumbnail.url}` : '/assets/placeholder.jpg'; + }; + + return ( +
+ {result.title} +
+

{result.title}

+

{result.author}

+
+
+ ); +}; + +const InvidiousSearchModal: React.FC = ({ isOpen, onClose, onSelectVideo }) => { + const [searchQuery, setSearchQuery] = useState(''); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const handleSearch = async () => { + if (!searchQuery.trim()) return; + + setIsLoading(true); + try { + const response = await fetch(getInvidiousSearchURL(searchQuery)); + const data = await response.json(); + + const videoResults = data.filter((item: InvidiousResult) => { + return item.type === 'video' || item.type === 'playlist' + }); + + setResults(videoResults); + } catch (error) { + console.error('Failed to search:', error); + } finally { + setIsLoading(false); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch(); + } + }; + + const _onSelectVideo = (url: string) => { + setSearchQuery(''); + setResults([]); + onSelectVideo(url); + }; + + if (!isOpen) return null; + + return ( +
+
+
+

Search YouTube (Invidious)

+ + +
+ +
+ setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search videos..." + className="p-2 rounded-lg border-2 border-violet-500 flex-grow bg-black/20 text-white" + /> + + +
+ +
+ {isLoading ? ( +
Searching...
+ ) : ( +
+ {results.map((result) => ( + _onSelectVideo(`https://www.youtube.com/watch?v=${result.videoId}`)} + /> + ))} +
+ )} +
+
+
+ ); +}; + +export default InvidiousSearchModal; \ No newline at end of file diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..51cba73 --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,6 @@ +export const USE_INVIDIOUS = import.meta.env.VITE_USE_INVIDIOUS || false; +export const INVIDIOUS_BASE_URL = import.meta.env.VITE_INVIDIOUS_BASE_URL || 'http://invidious.nor'; +export const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`; + +export const getInvidiousSearchURL = (query: string): string => + `${INVIDIOUS_API_ENDPOINT}/search?q=${encodeURIComponent(query)}`; \ No newline at end of file