Add searching feature

This commit is contained in:
2025-06-08 21:53:45 +09:00
parent cd0e4065fc
commit b7f3ca6a27
16 changed files with 564 additions and 194 deletions

View File

@ -9,9 +9,13 @@ import RSSEndpoints from "./components/RSSEndpoints";
function App() {
const location = useLocation();
const isMainPage = ["/", "/feeds", "/categories", "/feed-requests", "/rss-endpoints"].includes(
location.pathname,
);
const isMainPage = [
"/",
"/feeds",
"/categories",
"/feed-requests",
"/rss-endpoints",
].includes(location.pathname);
return (
<div className="container">

View File

@ -70,7 +70,6 @@ function CategoryList() {
return groupedFeeds[category]?.length || 0;
};
if (loading) {
return <div className="loading">...</div>;
}
@ -138,7 +137,10 @@ function CategoryList() {
<div className="category-feeds-preview">
{groupedFeeds[category]?.slice(0, 3).map((feed) => (
<div key={feed.id} className="feed-preview">
<Link to={`/feeds/${feed.id}`} className="feed-preview-link">
<Link
to={`/feeds/${feed.id}`}
className="feed-preview-link"
>
{feed.title || feed.url}
</Link>
</div>
@ -387,4 +389,4 @@ function CategoryList() {
);
}
export default CategoryList;
export default CategoryList;

View File

@ -182,11 +182,17 @@ function EpisodeDetail() {
<meta property="og:locale" content="ja_JP" />
{/* Image metadata */}
<meta property="og:image" content={`${window.location.origin}/default-thumbnail.svg`} />
<meta
property="og:image"
content={`${window.location.origin}/default-thumbnail.svg`}
/>
<meta property="og:image:type" content="image/svg+xml" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={`${episode.title} - Voice RSS Summary`} />
<meta
property="og:image:alt"
content={`${episode.title} - Voice RSS Summary`}
/>
{/* Twitter Card metadata */}
<meta name="twitter:card" content="summary_large_image" />
@ -195,8 +201,14 @@ function EpisodeDetail() {
name="twitter:description"
content={episode.description || `${episode.title}のエピソード詳細`}
/>
<meta name="twitter:image" content={`${window.location.origin}/default-thumbnail.svg`} />
<meta name="twitter:image:alt" content={`${episode.title} - Voice RSS Summary`} />
<meta
name="twitter:image"
content={`${window.location.origin}/default-thumbnail.svg`}
/>
<meta
name="twitter:image:alt"
content={`${episode.title} - Voice RSS Summary`}
/>
{/* Audio-specific metadata */}
<meta

View File

@ -32,9 +32,13 @@ interface EpisodeWithFeedInfo {
function EpisodeList() {
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
const [filteredEpisodes, setFilteredEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
const [filteredEpisodes, setFilteredEpisodes] = useState<
EpisodeWithFeedInfo[]
>([]);
const [categories, setCategories] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [searchQuery, setSearchQuery] = useState<string>("");
const [isSearching, setIsSearching] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<string | null>(null);
@ -46,8 +50,12 @@ function EpisodeList() {
}, [useDatabase]);
useEffect(() => {
filterEpisodesByCategory();
}, [episodes, selectedCategory]);
if (searchQuery.trim()) {
performSearch();
} else {
filterEpisodesByCategory();
}
}, [episodes, selectedCategory, searchQuery]);
const fetchEpisodes = async () => {
try {
@ -127,12 +135,41 @@ function EpisodeList() {
}
};
const performSearch = async () => {
try {
setIsSearching(true);
setError(null);
const searchParams = new URLSearchParams({
q: searchQuery.trim(),
});
if (selectedCategory) {
searchParams.append("category", selectedCategory);
}
const response = await fetch(`/api/episodes/search?${searchParams}`);
if (!response.ok) {
throw new Error("検索に失敗しました");
}
const data = await response.json();
setFilteredEpisodes(data.episodes || []);
} catch (err) {
console.error("Search error:", err);
setError(err instanceof Error ? err.message : "検索エラーが発生しました");
setFilteredEpisodes([]);
} finally {
setIsSearching(false);
}
};
const filterEpisodesByCategory = () => {
if (!selectedCategory) {
setFilteredEpisodes(episodes);
} else {
const filtered = episodes.filter(episode =>
episode.feedCategory === selectedCategory
const filtered = episodes.filter(
(episode) => episode.feedCategory === selectedCategory,
);
setFilteredEpisodes(filtered);
}
@ -197,19 +234,53 @@ function EpisodeList() {
return <div className="error">{error}</div>;
}
if (filteredEpisodes.length === 0 && episodes.length > 0 && selectedCategory) {
return (
<div className="empty-state">
<p>{selectedCategory}</p>
<button
className="btn btn-secondary"
onClick={() => setSelectedCategory("")}
style={{ marginTop: "10px" }}
>
</button>
</div>
);
if (filteredEpisodes.length === 0 && episodes.length > 0) {
if (searchQuery.trim()) {
return (
<div className="empty-state">
<p>{searchQuery}</p>
{selectedCategory && (
<p>{selectedCategory}</p>
)}
<div
style={{
marginTop: "10px",
display: "flex",
gap: "10px",
justifyContent: "center",
}}
>
<button
className="btn btn-secondary"
onClick={() => setSearchQuery("")}
>
</button>
{selectedCategory && (
<button
className="btn btn-secondary"
onClick={() => setSelectedCategory("")}
>
</button>
)}
</div>
</div>
);
} else if (selectedCategory) {
return (
<div className="empty-state">
<p>{selectedCategory}</p>
<button
className="btn btn-secondary"
onClick={() => setSelectedCategory("")}
style={{ marginTop: "10px" }}
>
</button>
</div>
);
}
}
if (episodes.length === 0) {
@ -235,22 +306,79 @@ function EpisodeList() {
<div
style={{
marginBottom: "20px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2> ({selectedCategory ? `${filteredEpisodes.length}/${episodes.length}` : episodes.length})</h2>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "15px",
}}
>
<h2>
(
{searchQuery
? `検索結果: ${filteredEpisodes.length}`
: selectedCategory
? `${filteredEpisodes.length}/${episodes.length}`
: `${episodes.length}`}
)
</h2>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
<span style={{ fontSize: "12px", color: "#666" }}>
: {useDatabase ? "データベース" : "XML"}
</span>
<button className="btn btn-secondary" onClick={fetchEpisodes}>
</button>
</div>
</div>
<div
style={{
display: "flex",
gap: "10px",
alignItems: "center",
flexWrap: "wrap",
}}
>
<input
type="text"
placeholder="エピソードを検索..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
flex: "1",
minWidth: "200px",
padding: "8px 12px",
fontSize: "14px",
border: "1px solid #ccc",
borderRadius: "4px",
}}
/>
{searchQuery && (
<button
className="btn btn-secondary"
onClick={() => setSearchQuery("")}
style={{
padding: "8px 12px",
fontSize: "14px",
}}
>
</button>
)}
{categories.length > 0 && (
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
style={{
padding: "5px 10px",
padding: "8px 12px",
fontSize: "14px",
border: "1px solid #ccc",
borderRadius: "4px",
minWidth: "120px",
}}
>
<option value=""></option>
@ -261,12 +389,9 @@ function EpisodeList() {
))}
</select>
)}
<span style={{ fontSize: "12px", color: "#666" }}>
: {useDatabase ? "データベース" : "XML"}
</span>
<button className="btn btn-secondary" onClick={fetchEpisodes}>
</button>
{isSearching && (
<span style={{ fontSize: "14px", color: "#666" }}>...</span>
)}
</div>
</div>
@ -309,7 +434,13 @@ function EpisodeList() {
{episode.feedTitle}
</Link>
{episode.feedCategory && (
<span style={{ marginLeft: "8px", color: "#999", fontSize: "11px" }}>
<span
style={{
marginLeft: "8px",
color: "#999",
fontSize: "11px",
}}
>
({episode.feedCategory})
</span>
)}

View File

@ -63,8 +63,8 @@ function FeedList() {
if (!selectedCategory) {
setFilteredFeeds(feeds);
} else {
const filtered = feeds.filter(feed =>
feed.category === selectedCategory
const filtered = feeds.filter(
(feed) => feed.category === selectedCategory,
);
setFilteredFeeds(filtered);
}
@ -125,7 +125,13 @@ function FeedList() {
alignItems: "center",
}}
>
<h2> ({selectedCategory ? `${filteredFeeds.length}/${feeds.length}` : feeds.length})</h2>
<h2>
(
{selectedCategory
? `${filteredFeeds.length}/${feeds.length}`
: feeds.length}
)
</h2>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
{categories.length > 0 && (
<select

View File

@ -61,7 +61,9 @@ export default function RSSEndpoints() {
<div className="rss-endpoints">
<div className="rss-endpoints-header">
<h1>RSS </h1>
<p>RSSフィードURLをポッドキャストアプリに追加して音声コンテンツをお楽しみください</p>
<p>
RSSフィードURLをポッドキャストアプリに追加して音声コンテンツをお楽しみください
</p>
</div>
{/* メインフィード */}
@ -182,18 +184,25 @@ export default function RSSEndpoints() {
<div className="usage-cards">
<div className="usage-card">
<h3>1. </h3>
<p>Apple PodcastsGoogle PodcastsSpotifyPocket Casts RSS 使</p>
<p>
Apple PodcastsGoogle PodcastsSpotifyPocket Casts RSS
使
</p>
</div>
<div className="usage-card">
<h3>2. RSS </h3>
<p>URLをコピーしてURLから購読使</p>
<p>
URLをコピーしてURLから購読使
</p>
</div>
<div className="usage-card">
<h3>3. </h3>
<p></p>
<p>
</p>
</div>
</div>
</section>
</div>
);
}
}

View File

@ -402,7 +402,7 @@ body {
background: none;
border: none;
color: #495057;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.9rem;
word-break: break-all;
flex: 1;
@ -419,7 +419,8 @@ body {
flex-shrink: 0;
}
.copy-btn, .open-btn {
.copy-btn,
.open-btn {
background: #3498db;
color: white;
border: none;
@ -435,7 +436,8 @@ body {
justify-content: center;
}
.copy-btn:hover, .open-btn:hover {
.copy-btn:hover,
.open-btn:hover {
background: #2980b9;
transform: scale(1.05);
}
@ -497,26 +499,26 @@ body {
.rss-endpoints {
padding: 0.5rem;
}
.rss-endpoints-grid {
grid-template-columns: 1fr;
}
.endpoint-url {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.endpoint-url code {
min-width: unset;
text-align: center;
}
.endpoint-actions {
justify-content: center;
}
.usage-cards {
grid-template-columns: 1fr;
}