From 803cdd2cdf166a62c427ed006a04020bb4a9efda Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 21 Feb 2025 22:47:50 -0800 Subject: [PATCH] feature: adds invidious search feature --- frontend/package-lock.json | 29 +++- frontend/package.json | 3 +- frontend/public/assets/placeholder.jpg | Bin 0 -> 8123 bytes frontend/src/components/AddSongPanel.tsx | 29 +++- .../src/components/InvidiousSearchModal.tsx | 135 ++++++++++++++++++ frontend/src/config.ts | 6 + 6 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 frontend/public/assets/placeholder.jpg create mode 100644 frontend/src/components/InvidiousSearchModal.tsx create mode 100644 frontend/src/config.ts 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 0000000000000000000000000000000000000000..8e9c2226987d3b273fc5a5ffdf76487d68843f9f GIT binary patch literal 8123 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 CE;D8T literal 0 HcmV?d00001 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