Update
This commit is contained in:
@ -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 />} />
|
||||
|
397
frontend/src/components/CategoryList.tsx
Normal file
397
frontend/src/components/CategoryList.tsx
Normal 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;
|
@ -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 &&
|
||||
|
@ -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>
|
||||
<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>
|
||||
);
|
||||
|
@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS feeds (
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
last_updated TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
active BOOLEAN DEFAULT 1
|
||||
|
@ -18,6 +18,7 @@ class BatchScheduler {
|
||||
};
|
||||
|
||||
private currentAbortController?: AbortController;
|
||||
private migrationCompleted = false;
|
||||
|
||||
private readonly SIX_HOURS_MS = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
|
||||
|
||||
@ -84,6 +85,28 @@ class BatchScheduler {
|
||||
|
||||
try {
|
||||
console.log("🔄 Running scheduled batch process...");
|
||||
|
||||
// Run migration for feeds without categories (only once)
|
||||
if (!this.migrationCompleted) {
|
||||
try {
|
||||
const { migrateFeedsWithCategories, getFeedCategoryMigrationStatus } = await import("./database.js");
|
||||
const migrationStatus = await getFeedCategoryMigrationStatus();
|
||||
|
||||
if (!migrationStatus.migrationComplete) {
|
||||
console.log("🔄 Running feed category migration...");
|
||||
await migrateFeedsWithCategories();
|
||||
this.migrationCompleted = true;
|
||||
} else {
|
||||
console.log("✅ Feed category migration already complete");
|
||||
this.migrationCompleted = true;
|
||||
}
|
||||
} catch (migrationError) {
|
||||
console.error("❌ Error during feed category migration:", migrationError);
|
||||
// Don't fail the entire batch process due to migration error
|
||||
this.migrationCompleted = true; // Mark as completed to avoid retrying every batch
|
||||
}
|
||||
}
|
||||
|
||||
await batchProcess(this.currentAbortController.signal);
|
||||
console.log("✅ Scheduled batch process completed");
|
||||
} catch (error) {
|
||||
|
@ -131,6 +131,7 @@ function initializeDatabase(): Database {
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
last_updated TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
active BOOLEAN DEFAULT 1
|
||||
@ -267,6 +268,7 @@ export interface EpisodeWithFeedInfo {
|
||||
feedId: string;
|
||||
feedTitle?: string;
|
||||
feedUrl: string;
|
||||
feedCategory?: string;
|
||||
}
|
||||
|
||||
// Feed management functions
|
||||
@ -280,11 +282,12 @@ export async function saveFeed(
|
||||
if (existingFeed) {
|
||||
// Update existing feed
|
||||
const updateStmt = db.prepare(
|
||||
"UPDATE feeds SET title = ?, description = ?, last_updated = ?, active = ? WHERE url = ?",
|
||||
"UPDATE feeds SET title = ?, description = ?, category = ?, last_updated = ?, active = ? WHERE url = ?",
|
||||
);
|
||||
updateStmt.run(
|
||||
feed.title || null,
|
||||
feed.description || null,
|
||||
feed.category || null,
|
||||
feed.lastUpdated || null,
|
||||
feed.active !== undefined ? (feed.active ? 1 : 0) : 1,
|
||||
feed.url,
|
||||
@ -296,13 +299,14 @@ export async function saveFeed(
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
const insertStmt = db.prepare(
|
||||
"INSERT INTO feeds (id, url, title, description, last_updated, created_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO feeds (id, url, title, description, category, last_updated, created_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
insertStmt.run(
|
||||
id,
|
||||
feed.url,
|
||||
feed.title || null,
|
||||
feed.description || null,
|
||||
feed.category || null,
|
||||
feed.lastUpdated || null,
|
||||
createdAt,
|
||||
feed.active !== undefined ? (feed.active ? 1 : 0) : 1,
|
||||
@ -407,7 +411,8 @@ export async function fetchEpisodesWithFeedInfo(): Promise<
|
||||
a.pub_date as articlePubDate,
|
||||
f.id as feedId,
|
||||
f.title as feedTitle,
|
||||
f.url as feedUrl
|
||||
f.url as feedUrl,
|
||||
f.category as feedCategory
|
||||
FROM episodes e
|
||||
JOIN articles a ON e.article_id = a.id
|
||||
JOIN feeds f ON a.feed_id = f.id
|
||||
@ -432,6 +437,7 @@ export async function fetchEpisodesWithFeedInfo(): Promise<
|
||||
feedId: row.feedId,
|
||||
feedTitle: row.feedTitle,
|
||||
feedUrl: row.feedUrl,
|
||||
feedCategory: row.feedCategory,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching episodes with feed info:", error);
|
||||
@ -459,7 +465,8 @@ export async function fetchEpisodesByFeedId(
|
||||
a.pub_date as articlePubDate,
|
||||
f.id as feedId,
|
||||
f.title as feedTitle,
|
||||
f.url as feedUrl
|
||||
f.url as feedUrl,
|
||||
f.category as feedCategory
|
||||
FROM episodes e
|
||||
JOIN articles a ON e.article_id = a.id
|
||||
JOIN feeds f ON a.feed_id = f.id
|
||||
@ -484,6 +491,7 @@ export async function fetchEpisodesByFeedId(
|
||||
feedId: row.feedId,
|
||||
feedTitle: row.feedTitle,
|
||||
feedUrl: row.feedUrl,
|
||||
feedCategory: row.feedCategory,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching episodes by feed ID:", error);
|
||||
@ -511,7 +519,8 @@ export async function fetchEpisodeWithSourceInfo(
|
||||
a.pub_date as articlePubDate,
|
||||
f.id as feedId,
|
||||
f.title as feedTitle,
|
||||
f.url as feedUrl
|
||||
f.url as feedUrl,
|
||||
f.category as feedCategory
|
||||
FROM episodes e
|
||||
JOIN articles a ON e.article_id = a.id
|
||||
JOIN feeds f ON a.feed_id = f.id
|
||||
@ -536,6 +545,7 @@ export async function fetchEpisodeWithSourceInfo(
|
||||
feedId: row.feedId,
|
||||
feedTitle: row.feedTitle,
|
||||
feedUrl: row.feedUrl,
|
||||
feedCategory: row.feedCategory,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching episode with source info:", error);
|
||||
@ -1104,6 +1114,100 @@ export async function updateFeedRequestStatus(
|
||||
}
|
||||
}
|
||||
|
||||
// Migration function to classify existing feeds without categories
|
||||
export async function migrateFeedsWithCategories(): Promise<void> {
|
||||
try {
|
||||
console.log("🔄 Starting feed category migration...");
|
||||
|
||||
// Get all feeds without categories
|
||||
const stmt = db.prepare("SELECT * FROM feeds WHERE category IS NULL OR category = ''");
|
||||
const feedsWithoutCategories = stmt.all() as any[];
|
||||
|
||||
if (feedsWithoutCategories.length === 0) {
|
||||
console.log("✅ All feeds already have categories assigned");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📋 Found ${feedsWithoutCategories.length} feeds without categories`);
|
||||
|
||||
// Import LLM service
|
||||
const { openAI_ClassifyFeed } = await import("./llm.js");
|
||||
|
||||
let processedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const feed of feedsWithoutCategories) {
|
||||
try {
|
||||
// Use title for classification, fallback to URL if no title
|
||||
const titleForClassification = feed.title || feed.url;
|
||||
|
||||
console.log(`🔍 Classifying feed: ${titleForClassification}`);
|
||||
|
||||
// Classify the feed
|
||||
const category = await openAI_ClassifyFeed(titleForClassification);
|
||||
|
||||
// Update the feed with the category
|
||||
const updateStmt = db.prepare("UPDATE feeds SET category = ? WHERE id = ?");
|
||||
updateStmt.run(category, feed.id);
|
||||
|
||||
console.log(`✅ Assigned category "${category}" to feed: ${titleForClassification}`);
|
||||
processedCount++;
|
||||
|
||||
// Add a small delay to avoid rate limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to classify feed ${feed.title || feed.url}:`, error);
|
||||
errorCount++;
|
||||
|
||||
// Set a default category for failed classifications
|
||||
const defaultCategory = "その他";
|
||||
const updateStmt = db.prepare("UPDATE feeds SET category = ? WHERE id = ?");
|
||||
updateStmt.run(defaultCategory, feed.id);
|
||||
console.log(`⚠️ Assigned default category "${defaultCategory}" to feed: ${feed.title || feed.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Feed category migration completed`);
|
||||
console.log(`📊 Processed: ${processedCount}, Errors: ${errorCount}, Total: ${feedsWithoutCategories.length}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Error during feed category migration:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to get migration status
|
||||
export async function getFeedCategoryMigrationStatus(): Promise<{
|
||||
totalFeeds: number;
|
||||
feedsWithCategories: number;
|
||||
feedsWithoutCategories: number;
|
||||
migrationComplete: boolean;
|
||||
}> {
|
||||
try {
|
||||
const totalStmt = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1");
|
||||
const totalResult = totalStmt.get() as any;
|
||||
const totalFeeds = totalResult.count;
|
||||
|
||||
const withCategoriesStmt = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1 AND category IS NOT NULL AND category != ''");
|
||||
const withCategoriesResult = withCategoriesStmt.get() as any;
|
||||
const feedsWithCategories = withCategoriesResult.count;
|
||||
|
||||
const feedsWithoutCategories = totalFeeds - feedsWithCategories;
|
||||
const migrationComplete = feedsWithoutCategories === 0;
|
||||
|
||||
return {
|
||||
totalFeeds,
|
||||
feedsWithCategories,
|
||||
feedsWithoutCategories,
|
||||
migrationComplete,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error getting migration status:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
db.close();
|
||||
}
|
||||
|
Reference in New Issue
Block a user