Add searching feature
This commit is contained in:
@ -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">
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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
|
||||
|
@ -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 Podcasts、Google Podcasts、Spotify、Pocket Casts など、RSS フィードに対応したポッドキャストアプリを使用してください。</p>
|
||||
<p>
|
||||
Apple Podcasts、Google Podcasts、Spotify、Pocket 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user