diff --git a/src/routes/_authed/search/-components/results/results-header.tsx b/src/routes/_authed/search/-components/results/results-header.tsx index 7259960..1a02013 100644 --- a/src/routes/_authed/search/-components/results/results-header.tsx +++ b/src/routes/_authed/search/-components/results/results-header.tsx @@ -5,7 +5,7 @@ interface ResultsHeaderProps { query: string; duration?: number; cached?: boolean; - type?: "result" | "image" | "file"; + type?: "result" | "image" | "file" | "video"; } export function ResultsHeader({ @@ -16,7 +16,13 @@ export function ResultsHeader({ type = "result", }: ResultsHeaderProps) { const typeLabel = - type === "image" ? "image" : type === "file" ? "file" : "result"; + type === "image" + ? "image" + : type === "file" + ? "file" + : type === "video" + ? "video" + : "result"; const pluralLabel = count !== 1 ? `${typeLabel}s` : typeLabel; const formatDuration = (ms: number): string => { diff --git a/src/routes/_authed/search/-components/results/search-loading.tsx b/src/routes/_authed/search/-components/results/search-loading.tsx index 431b0ba..ecd4564 100644 --- a/src/routes/_authed/search/-components/results/search-loading.tsx +++ b/src/routes/_authed/search/-components/results/search-loading.tsx @@ -1,6 +1,7 @@ import { SearchCategory } from "@/server/domain/value-objects"; import { FileResultsSkeleton } from "./file-results"; import { ImageResultsSkeleton } from "./image-results"; +import { VideoResultsSkeleton } from "./video-results"; import { WebResultsSkeleton } from "./web-results"; interface SearchResultsProps { @@ -16,6 +17,10 @@ export function SearchLoading({ category }: SearchResultsProps) { return ; } + if (category === SearchCategory.VIDEOS) { + return ; + } + if (category === SearchCategory.WEB) { return ; } diff --git a/src/routes/_authed/search/-components/results/search-results.tsx b/src/routes/_authed/search/-components/results/search-results.tsx index d23c71b..120bcae 100644 --- a/src/routes/_authed/search/-components/results/search-results.tsx +++ b/src/routes/_authed/search/-components/results/search-results.tsx @@ -3,10 +3,12 @@ import type { SearchResult } from "@/server/domain/value-objects"; import { isFileResult, isImageResult, + isVideoResult, isWebResult, } from "@/server/domain/value-objects"; import { FileResults } from "./file-results"; import { ImageResults } from "./image-results"; +import { VideoResults } from "./video-results"; import { WebResults } from "./web-results"; interface SearchResultsProps { @@ -103,6 +105,17 @@ export function SearchResults({ query, results }: SearchResultsProps) { ); } + if (isVideoResult(firstResult)) { + return ( + + ); + } + if (isWebResult(firstResult)) { return ( void; + onClose: () => void; +} + +function VideoCard({ + result, + linkTargetProps, + isActive, + onPlay, + onClose, +}: VideoCardProps) { + const [thumbnailError, setThumbnailError] = useState(false); + + const hostname = (() => { + try { + return new URL(result.url).hostname; + } catch { + return ""; + } + })(); + + const formattedDate = result.publishedDate + ? new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }).format(result.publishedDate) + : null; + + if (isActive && result.iframeSrc) { + return ( + + + + + + + + + + {result.title} + + + {hostname && {hostname}} + {hostname && formattedDate && ·} + {formattedDate && {formattedDate}} + + + + ); + } + + return ( + + result.iframeSrc && onPlay()} + aria-label={`Play ${result.title}`} + > + {result.thumbnail && !thumbnailError ? ( + setThumbnailError(true)} + className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" + /> + ) : ( + + + + )} + + + + {result.iframeSrc ? ( + + + + + + ) : ( + e.stopPropagation()} + aria-label={`Open ${result.title}`} + > + + + + + )} + + + + + {result.title} + + + {hostname && {hostname}} + {hostname && formattedDate && ·} + {formattedDate && {formattedDate}} + + {result.content && ( + + {result.content} + + )} + + + ); +} + +const SKELETON_IDS = Array.from({ length: 12 }, (_, i) => `sk-${i}`); + +export function VideoResultsSkeleton() { + return ( + + + + + + + {SKELETON_IDS.map((id, i) => ( + + + + + + + + + + ))} + + + ); +} + +export function VideoResults({ + query, + results, + duration, + cached, +}: VideoResultsProps) { + const linkTargetProps = useLinkTarget(); + const [activeIndex, setActiveIndex] = useState(null); + const cardRefs = useRef<(HTMLDivElement | null)[]>([]); + + useEffect(() => { + if (activeIndex === null) return; + const el = cardRefs.current[activeIndex]; + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, [activeIndex]); + + if (results.length === 0) { + return ( + + + + + + No videos found + + + Try adjusting your search terms + + + ); + } + + return ( + + + + + + {results.map((result, i) => ( + { + cardRefs.current[i] = el; + }} + className={`transition-all duration-200${activeIndex === i ? " col-span-full" : ""}`} + > + setActiveIndex(i)} + onClose={() => setActiveIndex(null)} + /> + + ))} + + + ); +} diff --git a/src/routes/_authed/search/-components/search-filters.tsx b/src/routes/_authed/search/-components/search-filters.tsx index 2d71432..f8c6da7 100644 --- a/src/routes/_authed/search/-components/search-filters.tsx +++ b/src/routes/_authed/search/-components/search-filters.tsx @@ -4,6 +4,7 @@ import { FileIcon, Globe, ImageIcon, + Video, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/client/components/ui/button"; @@ -26,6 +27,7 @@ const FILTERS: { }[] = [ { id: SearchCategory.WEB, icon: Globe }, { id: SearchCategory.IMAGES, icon: ImageIcon }, + { id: SearchCategory.VIDEOS, icon: Video }, { id: SearchCategory.FILES, icon: FileIcon }, ]; diff --git a/src/server/domain/value-objects/search.vo.ts b/src/server/domain/value-objects/search.vo.ts index 3bfc366..464e05b 100644 --- a/src/server/domain/value-objects/search.vo.ts +++ b/src/server/domain/value-objects/search.vo.ts @@ -19,6 +19,7 @@ export const SearchCategory = { WEB: "web", IMAGES: "images", FILES: "files", + VIDEOS: "videos", } as const; export type SearchCategory = @@ -28,6 +29,7 @@ export const ResultType = { WEB: "web", IMAGE: "image", FILE: "file", + VIDEO: "video", } as const; export type ResultType = (typeof ResultType)[keyof typeof ResultType]; @@ -54,10 +56,19 @@ export interface FileResultEntry extends BaseResultEntry { extension: string; } +export interface VideoResultEntry extends BaseResultEntry { + type: typeof ResultType.VIDEO; + thumbnail: string | undefined; + iframeSrc: string | undefined; + content: string; + publishedDate: Date | undefined; +} + export type SearchResultEntry = | WebResultEntry | ImageResultEntry - | FileResultEntry; + | FileResultEntry + | VideoResultEntry; export function isWebResult( result: SearchResultEntry, @@ -77,6 +88,12 @@ export function isFileResult( return result.type === ResultType.FILE; } +export function isVideoResult( + result: SearchResultEntry, +): result is VideoResultEntry { + return result.type === ResultType.VIDEO; +} + export interface SearchInput { query: string; category?: SearchCategory; diff --git a/src/server/infrastructure/http/searxng/search-engine.ts b/src/server/infrastructure/http/searxng/search-engine.ts index 6a87ed7..2ccd41d 100644 --- a/src/server/infrastructure/http/searxng/search-engine.ts +++ b/src/server/infrastructure/http/searxng/search-engine.ts @@ -6,6 +6,7 @@ import type { FileResultEntry, ImageResultEntry, SuggestResult, + VideoResultEntry, WebResultEntry, } from "@/server/domain/value-objects"; import { @@ -86,6 +87,7 @@ const toSearXngCategoriesSearchParams = ({ [SearchCategory.WEB]: undefined, [SearchCategory.IMAGES]: ["images"], [SearchCategory.FILES]: ["files"], + [SearchCategory.VIDEOS]: ["videos"], }; return map[category]?.join(","); @@ -161,42 +163,60 @@ export const makeSearXngSearchEngine = ({ const result: BaseSearchResult = { results: searXngResponse.results - .filter((r) => { - if (category === SearchCategory.IMAGES) { - return ( - resolveImageUrl(r.img_src) != null || - resolveImageUrl(r.thumbnail) != null - ); - } - return true; - }) - .map((r): WebResultEntry | ImageResultEntry | FileResultEntry => { - if (category === SearchCategory.IMAGES) { - return { - type: ResultType.IMAGE, - title: r.title, - url: r.url, - imageSrc: resolveImageUrl(r.img_src) ?? "", - thumbnail: resolveImageUrl(r.thumbnail), - }; - } + .map( + ( + r, + ): + | WebResultEntry + | ImageResultEntry + | FileResultEntry + | VideoResultEntry => { + if (category === SearchCategory.IMAGES) { + return { + type: ResultType.IMAGE, + title: r.title, + url: r.url, + imageSrc: resolveImageUrl(r.img_src) ?? "", + thumbnail: resolveImageUrl(r.thumbnail), + }; + } + + if (category === SearchCategory.FILES) { + return { + type: ResultType.FILE, + title: r.title, + url: r.url, + extension: extractFileExtension(r.url), + }; + } + + if (category === SearchCategory.VIDEOS) { + return { + type: ResultType.VIDEO, + title: r.title, + url: r.url, + thumbnail: resolveImageUrl(r.thumbnail), + iframeSrc: r.iframe_src ?? undefined, + content: r.content ?? "", + publishedDate: r.publishedDate ?? r.pubdate ?? undefined, + }; + } - if (category === SearchCategory.FILES) { return { - type: ResultType.FILE, + type: ResultType.WEB, title: r.title, url: r.url, - extension: extractFileExtension(r.url), + content: r.content ?? "", + publishedDate: r.publishedDate ?? r.pubdate ?? undefined, }; + }, + ) + .filter((r) => { + if (category === SearchCategory.IMAGES) { + const img = r as ImageResultEntry; + return img.imageSrc || img.thumbnail; } - - return { - type: ResultType.WEB, - title: r.title, - url: r.url, - content: r.content ?? "", - publishedDate: r.publishedDate ?? r.pubdate ?? undefined, - }; + return true; }), count: searXngResponse.results.length, };
+ {result.content} +
+ Try adjusting your search terms +