Add a global search feature to find articles and videos
Introduces a global search functionality with a dedicated search page and integrates a search icon/input into the header and mobile menu. The search queries articles by title, excerpt, and content, and videos by title and description. Updates are made to `App.tsx` to include the new route, and `header.tsx` to implement the search UI and logic. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 517dfa7b-26ac-463d-a6e1-a58c6df97188 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 90e58e4e-8d41-41e7-bc45-74196314bd78 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/517dfa7b-26ac-463d-a6e1-a58c6df97188/jdAEdU5 Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
740595b231
commit
969c6912ed
BIN
attached_assets/image_1772614711397.png
Normal file
BIN
attached_assets/image_1772614711397.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@ -11,11 +11,13 @@ import VideosPage from "@/pages/videos";
|
||||
import GalleryPageWrapper from "@/pages/gallery";
|
||||
import HoroscopePage from "@/pages/horoscope";
|
||||
import RecipesPage from "@/pages/recipes";
|
||||
import SearchPage from "@/pages/search";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/search" component={SearchPage} />
|
||||
<Route path="/article/:slug" component={ArticlePage} />
|
||||
<Route path="/category/:category" component={CategoryPage} />
|
||||
<Route path="/videos" component={VideosPage} />
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Menu, X, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import folxLogo from "@assets/folx_MT_poz_b_1772296729169.png";
|
||||
|
||||
const navItems = [
|
||||
@ -15,7 +15,26 @@ const navItems = [
|
||||
|
||||
export default function Header() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [location] = useLocation();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [location, navigate] = useLocation();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [searchOpen]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim().length >= 2) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
setMobileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-background/95 backdrop-blur-md border-b border-card-border" data-testid="header">
|
||||
@ -42,6 +61,39 @@ export default function Header() {
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{searchOpen ? (
|
||||
<form onSubmit={handleSearch} className="flex items-center gap-1" data-testid="form-header-search">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Suchen..."
|
||||
className="w-32 sm:w-48 px-3 py-1.5 bg-card border border-card-border rounded-md text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
data-testid="input-header-search"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => { setSearchOpen(false); setSearchQuery(""); }}
|
||||
data-testid="button-close-search"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
data-testid="button-open-search"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@ -58,6 +110,19 @@ export default function Header() {
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden border-t border-card-border bg-card" data-testid="nav-mobile">
|
||||
<nav className="flex flex-col p-4 gap-1">
|
||||
<form onSubmit={handleSearch} className="mb-2" data-testid="form-mobile-search">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Artikel, Videos suchen..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-background border border-card-border rounded-md text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
data-testid="input-mobile-search"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{navItems.map((item) => (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<Button
|
||||
|
||||
184
client/src/pages/search.tsx
Normal file
184
client/src/pages/search.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Link } from "wouter";
|
||||
import { Search, Play, FileText, ArrowLeft } from "lucide-react";
|
||||
import Header from "@/components/header";
|
||||
|
||||
interface SearchResult {
|
||||
articles: {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
coverImage: string;
|
||||
category: string;
|
||||
author: string;
|
||||
publishedAt: string;
|
||||
}[];
|
||||
videos: {
|
||||
guid: string;
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
embedUrl: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const [location] = useLocation();
|
||||
const params = new URLSearchParams(location.split("?")[1] || "");
|
||||
const initialQuery = params.get("q") || "";
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [searchTerm, setSearchTerm] = useState(initialQuery);
|
||||
|
||||
useEffect(() => {
|
||||
const p = new URLSearchParams(location.split("?")[1] || "");
|
||||
const q = p.get("q") || "";
|
||||
setQuery(q);
|
||||
setSearchTerm(q);
|
||||
}, [location]);
|
||||
|
||||
const { data, isLoading } = useQuery<SearchResult>({
|
||||
queryKey: ["/api/search", searchTerm],
|
||||
queryFn: async () => {
|
||||
if (!searchTerm || searchTerm.length < 2) return { articles: [], videos: [] };
|
||||
const resp = await fetch(`/api/search?q=${encodeURIComponent(searchTerm)}`);
|
||||
return resp.json();
|
||||
},
|
||||
enabled: searchTerm.length >= 2,
|
||||
});
|
||||
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim().length >= 2) {
|
||||
setSearchTerm(query.trim());
|
||||
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
const totalResults = (data?.articles?.length || 0) + (data?.videos?.length || 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background" data-testid="page-search">
|
||||
<Header />
|
||||
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<Link href="/">
|
||||
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground text-sm mb-4 transition-colors" data-testid="link-back-home">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurück
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mb-6" data-testid="form-search">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Artikel, Videos, Künstler suchen..."
|
||||
className="w-full pl-12 pr-4 py-3 bg-card border border-card-border rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary text-base"
|
||||
autoFocus
|
||||
data-testid="input-search"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{searchTerm.length >= 2 && (
|
||||
<p className="text-muted-foreground text-sm mb-4" data-testid="text-result-count">
|
||||
{isLoading ? "Suche läuft..." : `${totalResults} Ergebnis${totalResults !== 1 ? "se" : ""} für "${searchTerm}"`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-24 bg-card rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.articles.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-bold text-foreground mb-3 flex items-center gap-2" data-testid="heading-articles">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
Artikel ({data.articles.length})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{data.articles.map((article) => (
|
||||
<Link key={article.id} href={`/article/${article.slug}`}>
|
||||
<div className="flex gap-4 bg-card border border-card-border rounded-lg p-3 hover:border-primary/50 transition-colors cursor-pointer" data-testid={`search-article-${article.id}`}>
|
||||
{article.coverImage && (
|
||||
<img
|
||||
src={article.coverImage}
|
||||
alt={article.title}
|
||||
className="w-24 h-24 sm:w-32 sm:h-20 object-cover rounded-md flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-primary font-medium">{article.category}</span>
|
||||
<h3 className="text-sm font-bold text-card-foreground line-clamp-2 mt-0.5">{article.title}</h3>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">{article.excerpt}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.videos.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-bold text-foreground mb-3 flex items-center gap-2" data-testid="heading-videos">
|
||||
<Play className="w-5 h-5 text-primary" />
|
||||
Videos ({data.videos.length})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{data.videos.map((video) => (
|
||||
<a
|
||||
key={video.guid}
|
||||
href={video.embedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex gap-4 bg-card border border-card-border rounded-lg p-3 hover:border-primary/50 transition-colors cursor-pointer"
|
||||
data-testid={`search-video-${video.guid}`}
|
||||
>
|
||||
<div className="relative w-24 h-24 sm:w-32 sm:h-20 flex-shrink-0">
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt={video.title}
|
||||
className="w-full h-full object-cover rounded-md"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/80 flex items-center justify-center">
|
||||
<Play className="w-4 h-4 text-white fill-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-primary font-medium">Video</span>
|
||||
<h3 className="text-sm font-bold text-card-foreground line-clamp-2 mt-0.5">{video.title}</h3>
|
||||
{video.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">{video.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && totalResults === 0 && searchTerm.length >= 2 && !isLoading && (
|
||||
<div className="text-center py-12" data-testid="text-no-results">
|
||||
<Search className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">Keine Ergebnisse für "{searchTerm}" gefunden.</p>
|
||||
<p className="text-muted-foreground text-sm mt-1">Versuchen Sie einen anderen Suchbegriff.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -90,6 +90,50 @@ export async function registerRoutes(
|
||||
res.json(articles);
|
||||
});
|
||||
|
||||
app.get("/api/search", async (req, res) => {
|
||||
const q = (req.query.q as string || "").trim().toLowerCase();
|
||||
if (!q || q.length < 2) return res.json({ articles: [], videos: [] });
|
||||
|
||||
try {
|
||||
const articles = await storage.getArticles();
|
||||
const matchedArticles = articles.filter(a =>
|
||||
a.title.toLowerCase().includes(q) ||
|
||||
a.excerpt.toLowerCase().includes(q) ||
|
||||
a.content.replace(/<[^>]+>/g, "").toLowerCase().includes(q) ||
|
||||
a.category.toLowerCase().includes(q)
|
||||
).slice(0, 10);
|
||||
|
||||
const cacheKey = `videos_1_100_`;
|
||||
let videos: any[] = [];
|
||||
const cached = getCached<any>(cacheKey, 30 * 60 * 1000);
|
||||
if (cached) {
|
||||
videos = cached.items || [];
|
||||
} else {
|
||||
try {
|
||||
const data = await bunnyFetch(`/library/${LIBRARY_ID}/videos?page=1&itemsPerPage=100&orderBy=date`);
|
||||
videos = (data.items || []).map((v: any) => {
|
||||
const descTag = (v.metaTags || []).find((t: any) => t.property === "description");
|
||||
return {
|
||||
guid: v.guid,
|
||||
title: (v.title || "").replace(/\.mp4$/i, ""),
|
||||
description: descTag?.value || v.description || "",
|
||||
thumbnail: `https://${CDN_HOST}/${v.guid}/${v.thumbnailFileName || "thumbnail.jpg"}`,
|
||||
embedUrl: `https://player.mediadelivery.net/embed/${LIBRARY_ID}/${v.guid}`,
|
||||
};
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
const matchedVideos = videos.filter((v: any) =>
|
||||
v.title.toLowerCase().includes(q) ||
|
||||
(v.description || "").toLowerCase().includes(q)
|
||||
).slice(0, 10);
|
||||
|
||||
res.json({ articles: matchedArticles, videos: matchedVideos });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/articles/:slug", async (req, res) => {
|
||||
const article = await storage.getArticleBySlug(req.params.slug);
|
||||
if (!article) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user