This commit is contained in:
2025-06-08 17:48:07 +09:00
parent 41a433893b
commit f34c601ec0
7 changed files with 705 additions and 13 deletions

View File

@ -1,4 +1,5 @@
import { Link, Route, Routes, useLocation } from "react-router-dom";
import CategoryList from "./components/CategoryList";
import EpisodeDetail from "./components/EpisodeDetail";
import EpisodeList from "./components/EpisodeList";
import FeedDetail from "./components/FeedDetail";
@ -7,7 +8,7 @@ import FeedManager from "./components/FeedManager";
function App() {
const location = useLocation();
const isMainPage = ["/", "/feeds", "/feed-requests"].includes(
const isMainPage = ["/", "/feeds", "/categories", "/feed-requests"].includes(
location.pathname,
);
@ -35,6 +36,12 @@ function App() {
>
</Link>
<Link
to="/categories"
className={`tab ${location.pathname === "/categories" ? "active" : ""}`}
>
</Link>
<Link
to="/feed-requests"
className={`tab ${location.pathname === "/feed-requests" ? "active" : ""}`}
@ -49,6 +56,7 @@ function App() {
<Routes>
<Route path="/" element={<EpisodeList />} />
<Route path="/feeds" element={<FeedList />} />
<Route path="/categories" element={<CategoryList />} />
<Route path="/feed-requests" element={<FeedManager />} />
<Route path="/episode/:episodeId" element={<EpisodeDetail />} />
<Route path="/feeds/:feedId" element={<FeedDetail />} />

View File

@ -0,0 +1,397 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
interface Feed {
id: string;
url: string;
title?: string;
description?: string;
category?: string;
lastUpdated?: string;
createdAt: string;
active: boolean;
}
interface CategoryGroup {
[category: string]: Feed[];
}
function CategoryList() {
const [groupedFeeds, setGroupedFeeds] = useState<CategoryGroup>({});
const [categories, setCategories] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [filteredFeeds, setFilteredFeeds] = useState<Feed[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchCategoriesAndFeeds();
}, []);
useEffect(() => {
if (selectedCategory && groupedFeeds[selectedCategory]) {
setFilteredFeeds(groupedFeeds[selectedCategory]);
} else {
setFilteredFeeds([]);
}
}, [selectedCategory, groupedFeeds]);
const fetchCategoriesAndFeeds = async () => {
try {
setLoading(true);
setError(null);
// Fetch grouped feeds
const [groupedResponse, categoriesResponse] = await Promise.all([
fetch("/api/feeds/grouped-by-category"),
fetch("/api/categories"),
]);
if (!groupedResponse.ok || !categoriesResponse.ok) {
throw new Error("カテゴリデータの取得に失敗しました");
}
const groupedData = await groupedResponse.json();
const categoriesData = await categoriesResponse.json();
setGroupedFeeds(groupedData.groupedFeeds || {});
setCategories(categoriesData.categories || []);
} catch (err) {
console.error("Category fetch error:", err);
setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString("ja-JP");
};
const getFeedCount = (category: string) => {
return groupedFeeds[category]?.length || 0;
};
const getTotalEpisodesCount = (feeds: Feed[]) => {
// This would require an additional API call to get episode counts per feed
// For now, just return the feed count
return feeds.length;
};
if (loading) {
return <div className="loading">...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
const availableCategories = Object.keys(groupedFeeds).filter(
(category) => groupedFeeds[category] && groupedFeeds[category].length > 0,
);
if (availableCategories.length === 0) {
return (
<div className="empty-state">
<p></p>
<p>
</p>
<button
className="btn btn-secondary"
onClick={fetchCategoriesAndFeeds}
style={{ marginTop: "10px" }}
>
</button>
</div>
);
}
return (
<div>
<div
style={{
marginBottom: "20px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2> ({availableCategories.length})</h2>
<button className="btn btn-secondary" onClick={fetchCategoriesAndFeeds}>
</button>
</div>
{!selectedCategory ? (
<div className="category-grid">
{availableCategories.map((category) => (
<div key={category} className="category-card">
<div className="category-header">
<h3
className="category-title"
onClick={() => setSelectedCategory(category)}
>
{category}
</h3>
<div className="category-stats">
<span className="feed-count">
{getFeedCount(category)}
</span>
</div>
</div>
<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">
{feed.title || feed.url}
</Link>
</div>
))}
{getFeedCount(category) > 3 && (
<div className="more-feeds">
{getFeedCount(category) - 3} ...
</div>
)}
</div>
<div className="category-actions">
<button
className="btn btn-primary"
onClick={() => setSelectedCategory(category)}
>
</button>
</div>
</div>
))}
</div>
) : (
<div>
<div className="category-detail-header">
<button
className="btn btn-secondary"
onClick={() => setSelectedCategory(null)}
>
</button>
<h3>: {selectedCategory}</h3>
<p>{filteredFeeds.length} </p>
</div>
<div className="feed-grid">
{filteredFeeds.map((feed) => (
<div key={feed.id} className="feed-card">
<div className="feed-card-header">
<h4 className="feed-title">
<Link to={`/feeds/${feed.id}`} className="feed-link">
{feed.title || feed.url}
</Link>
</h4>
<div className="feed-url">
<a
href={feed.url}
target="_blank"
rel="noopener noreferrer"
>
{feed.url}
</a>
</div>
</div>
{feed.description && (
<div className="feed-description">{feed.description}</div>
)}
<div className="feed-meta">
<div>: {formatDate(feed.createdAt)}</div>
{feed.lastUpdated && (
<div>: {formatDate(feed.lastUpdated)}</div>
)}
</div>
<div className="feed-actions">
<Link to={`/feeds/${feed.id}`} className="btn btn-primary">
</Link>
</div>
</div>
))}
</div>
</div>
)}
<style>{`
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.category-card {
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 20px;
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.category-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
}
.category-header {
margin-bottom: 15px;
}
.category-title {
margin: 0 0 8px 0;
font-size: 20px;
color: #2c3e50;
cursor: pointer;
transition: color 0.2s ease;
}
.category-title:hover {
color: #007bff;
}
.category-stats {
display: flex;
gap: 15px;
font-size: 14px;
color: #6c757d;
}
.feed-count {
background: #f8f9fa;
padding: 4px 8px;
border-radius: 4px;
}
.category-feeds-preview {
margin-bottom: 15px;
}
.feed-preview {
margin-bottom: 6px;
font-size: 14px;
}
.feed-preview-link {
color: #007bff;
text-decoration: none;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feed-preview-link:hover {
text-decoration: underline;
}
.more-feeds {
font-size: 12px;
color: #6c757d;
font-style: italic;
}
.category-actions {
text-align: right;
}
.category-detail-header {
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #e9ecef;
}
.category-detail-header h3 {
margin: 10px 0 5px 0;
color: #2c3e50;
}
.category-detail-header p {
margin: 0;
color: #6c757d;
}
.feed-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.feed-card {
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.feed-card-header {
margin-bottom: 15px;
}
.feed-title {
margin: 0 0 8px 0;
font-size: 16px;
}
.feed-link {
text-decoration: none;
color: #007bff;
}
.feed-link:hover {
text-decoration: underline;
}
.feed-url {
font-size: 12px;
color: #666;
word-break: break-all;
}
.feed-url a {
color: #666;
text-decoration: none;
}
.feed-url a:hover {
color: #007bff;
}
.feed-description {
margin-bottom: 15px;
color: #333;
line-height: 1.5;
font-size: 14px;
}
.feed-meta {
margin-bottom: 15px;
font-size: 12px;
color: #666;
}
.feed-meta div {
margin-bottom: 4px;
}
.feed-actions {
text-align: right;
}
`}</style>
</div>
);
}
export default CategoryList;

View File

@ -27,10 +27,14 @@ interface EpisodeWithFeedInfo {
feedId: string;
feedTitle?: string;
feedUrl: string;
feedCategory?: string;
}
function EpisodeList() {
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
const [filteredEpisodes, setFilteredEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<string | null>(null);
@ -38,8 +42,13 @@ function EpisodeList() {
useEffect(() => {
fetchEpisodes();
fetchCategories();
}, [useDatabase]);
useEffect(() => {
filterEpisodesByCategory();
}, [episodes, selectedCategory]);
const fetchEpisodes = async () => {
try {
setLoading(true);
@ -106,6 +115,29 @@ function EpisodeList() {
}
};
const fetchCategories = async () => {
try {
const response = await fetch("/api/categories");
if (response.ok) {
const data = await response.json();
setCategories(data.categories || []);
}
} catch (err) {
console.error("Error fetching categories:", err);
}
};
const filterEpisodesByCategory = () => {
if (!selectedCategory) {
setFilteredEpisodes(episodes);
} else {
const filtered = episodes.filter(episode =>
episode.feedCategory === selectedCategory
);
setFilteredEpisodes(filtered);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString("ja-JP");
};
@ -165,6 +197,21 @@ 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 (episodes.length === 0) {
return (
<div className="empty-state">
@ -193,8 +240,27 @@ function EpisodeList() {
alignItems: "center",
}}
>
<h2> ({episodes.length})</h2>
<h2> ({selectedCategory ? `${filteredEpisodes.length}/${episodes.length}` : episodes.length})</h2>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
{categories.length > 0 && (
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
style={{
padding: "5px 10px",
fontSize: "14px",
border: "1px solid #ccc",
borderRadius: "4px",
}}
>
<option value=""></option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
)}
<span style={{ fontSize: "12px", color: "#666" }}>
: {useDatabase ? "データベース" : "XML"}
</span>
@ -214,7 +280,7 @@ function EpisodeList() {
</tr>
</thead>
<tbody>
{episodes.map((episode) => (
{filteredEpisodes.map((episode) => (
<tr key={episode.id}>
<td>
<div style={{ marginBottom: "8px" }}>
@ -242,6 +308,11 @@ function EpisodeList() {
>
{episode.feedTitle}
</Link>
{episode.feedCategory && (
<span style={{ marginLeft: "8px", color: "#999", fontSize: "11px" }}>
({episode.feedCategory})
</span>
)}
</div>
)}
{episode.articleTitle &&

View File

@ -6,6 +6,7 @@ interface Feed {
url: string;
title?: string;
description?: string;
category?: string;
lastUpdated?: string;
createdAt: string;
active: boolean;
@ -13,13 +14,21 @@ interface Feed {
function FeedList() {
const [feeds, setFeeds] = useState<Feed[]>([]);
const [filteredFeeds, setFilteredFeeds] = useState<Feed[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchFeeds();
fetchCategories();
}, []);
useEffect(() => {
filterFeedsByCategory();
}, [feeds, selectedCategory]);
const fetchFeeds = async () => {
try {
setLoading(true);
@ -38,6 +47,29 @@ function FeedList() {
}
};
const fetchCategories = async () => {
try {
const response = await fetch("/api/categories");
if (response.ok) {
const data = await response.json();
setCategories(data.categories || []);
}
} catch (err) {
console.error("Error fetching categories:", err);
}
};
const filterFeedsByCategory = () => {
if (!selectedCategory) {
setFilteredFeeds(feeds);
} else {
const filtered = feeds.filter(feed =>
feed.category === selectedCategory
);
setFilteredFeeds(filtered);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString("ja-JP");
};
@ -50,6 +82,21 @@ function FeedList() {
return <div className="error">{error}</div>;
}
if (filteredFeeds.length === 0 && feeds.length > 0 && selectedCategory) {
return (
<div className="empty-state">
<p>{selectedCategory}</p>
<button
className="btn btn-secondary"
onClick={() => setSelectedCategory("")}
style={{ marginTop: "10px" }}
>
</button>
</div>
);
}
if (feeds.length === 0) {
return (
<div className="empty-state">
@ -78,14 +125,35 @@ function FeedList() {
alignItems: "center",
}}
>
<h2> ({feeds.length})</h2>
<button className="btn btn-secondary" onClick={fetchFeeds}>
</button>
<h2> ({selectedCategory ? `${filteredFeeds.length}/${feeds.length}` : feeds.length})</h2>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
{categories.length > 0 && (
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
style={{
padding: "5px 10px",
fontSize: "14px",
border: "1px solid #ccc",
borderRadius: "4px",
}}
>
<option value=""></option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
)}
<button className="btn btn-secondary" onClick={fetchFeeds}>
</button>
</div>
</div>
<div className="feed-grid">
{feeds.map((feed) => (
{filteredFeeds.map((feed) => (
<div key={feed.id} className="feed-card">
<div className="feed-card-header">
<h3 className="feed-title">
@ -104,6 +172,12 @@ function FeedList() {
<div className="feed-description">{feed.description}</div>
)}
{feed.category && (
<div className="feed-category">
<span className="category-badge">{feed.category}</span>
</div>
)}
<div className="feed-meta">
<div>: {formatDate(feed.createdAt)}</div>
{feed.lastUpdated && (
@ -187,6 +261,20 @@ function FeedList() {
.feed-actions {
text-align: right;
}
.feed-category {
margin-bottom: 15px;
}
.category-badge {
display: inline-block;
background: #007bff;
color: white;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
`}</style>
</div>
);