Some fixes

This commit is contained in:
2025-06-11 23:03:40 +09:00
parent 71d3f1912d
commit 6e2700fe3d
8 changed files with 423 additions and 193 deletions

View File

@ -102,11 +102,26 @@ function App() {
const [approvalNotes, setApprovalNotes] = useState<{ [key: string]: string }>( const [approvalNotes, setApprovalNotes] = useState<{ [key: string]: string }>(
{}, {},
); );
const [editingSettings, setEditingSettings] = useState<{ [key: string]: string }>({}); const [editingSettings, setEditingSettings] = useState<{
const [categories, setCategories] = useState<CategoryData>({ feedCategories: [], episodeCategories: [], allCategories: [] }); [key: string]: string;
const [categoryCounts, setCategoryCounts] = useState<{ [category: string]: CategoryCounts }>({}); }>({});
const [categories, setCategories] = useState<CategoryData>({
feedCategories: [],
episodeCategories: [],
allCategories: [],
});
const [categoryCounts, setCategoryCounts] = useState<{
[category: string]: CategoryCounts;
}>({});
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
"dashboard" | "feeds" | "episodes" | "env" | "settings" | "batch" | "requests" | "categories" | "dashboard"
| "feeds"
| "episodes"
| "env"
| "settings"
| "batch"
| "requests"
| "categories"
>("dashboard"); >("dashboard");
useEffect(() => { useEffect(() => {
@ -116,16 +131,23 @@ function App() {
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
try { try {
const [feedsRes, statsRes, envRes, settingsRes, requestsRes, episodesRes, categoriesRes] = const [
await Promise.all([ feedsRes,
fetch("/api/admin/feeds"), statsRes,
fetch("/api/admin/stats"), envRes,
fetch("/api/admin/env"), settingsRes,
fetch("/api/admin/settings"), requestsRes,
fetch("/api/admin/feed-requests"), episodesRes,
fetch("/api/admin/episodes"), categoriesRes,
fetch("/api/admin/categories/all"), ] = await Promise.all([
]); fetch("/api/admin/feeds"),
fetch("/api/admin/stats"),
fetch("/api/admin/env"),
fetch("/api/admin/settings"),
fetch("/api/admin/feed-requests"),
fetch("/api/admin/episodes"),
fetch("/api/admin/categories/all"),
]);
if ( if (
!feedsRes.ok || !feedsRes.ok ||
@ -139,16 +161,23 @@ function App() {
throw new Error("Failed to load data"); throw new Error("Failed to load data");
} }
const [feedsData, statsData, envData, settingsData, requestsData, episodesData, categoriesData] = const [
await Promise.all([ feedsData,
feedsRes.json(), statsData,
statsRes.json(), envData,
envRes.json(), settingsData,
settingsRes.json(), requestsData,
requestsRes.json(), episodesData,
episodesRes.json(), categoriesData,
categoriesRes.json(), ] = await Promise.all([
]); feedsRes.json(),
statsRes.json(),
envRes.json(),
settingsRes.json(),
requestsRes.json(),
episodesRes.json(),
categoriesRes.json(),
]);
setFeeds(feedsData); setFeeds(feedsData);
setStats(statsData); setStats(statsData);
@ -157,24 +186,28 @@ function App() {
setFeedRequests(requestsData); setFeedRequests(requestsData);
setEpisodes(episodesData); setEpisodes(episodesData);
setCategories(categoriesData); setCategories(categoriesData);
// Load category counts for all categories // Load category counts for all categories
const countsPromises = categoriesData.allCategories.map(async (category: string) => { const countsPromises = categoriesData.allCategories.map(
const res = await fetch(`/api/admin/categories/${encodeURIComponent(category)}/counts`); async (category: string) => {
if (res.ok) { const res = await fetch(
const counts = await res.json(); `/api/admin/categories/${encodeURIComponent(category)}/counts`,
return { category, counts }; );
} if (res.ok) {
return { category, counts: { feedCount: 0, episodeCount: 0 } }; const counts = await res.json();
}); return { category, counts };
}
return { category, counts: { feedCount: 0, episodeCount: 0 } };
},
);
const countsResults = await Promise.all(countsPromises); const countsResults = await Promise.all(countsPromises);
const countsMap: { [category: string]: CategoryCounts } = {}; const countsMap: { [category: string]: CategoryCounts } = {};
countsResults.forEach(({ category, counts }) => { countsResults.forEach(({ category, counts }) => {
countsMap[category] = counts; countsMap[category] = counts;
}); });
setCategoryCounts(countsMap); setCategoryCounts(countsMap);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError("データの読み込みに失敗しました"); setError("データの読み込みに失敗しました");
@ -426,26 +459,44 @@ function App() {
setEditingSettings({ ...editingSettings, [key]: value }); setEditingSettings({ ...editingSettings, [key]: value });
}; };
const deleteCategory = async (category: string, target: "feeds" | "episodes" | "both") => { const deleteCategory = async (
const targetText = target === "both" ? "フィードとエピソード" : target === "feeds" ? "フィード" : "エピソード"; category: string,
const counts = categoryCounts[category] || { feedCount: 0, episodeCount: 0 }; target: "feeds" | "episodes" | "both",
const totalCount = target === "both" ? counts.feedCount + counts.episodeCount : ) => {
target === "feeds" ? counts.feedCount : counts.episodeCount; const targetText =
target === "both"
? "フィードとエピソード"
: target === "feeds"
? "フィード"
: "エピソード";
const counts = categoryCounts[category] || {
feedCount: 0,
episodeCount: 0,
};
const totalCount =
target === "both"
? counts.feedCount + counts.episodeCount
: target === "feeds"
? counts.feedCount
: counts.episodeCount;
if ( if (
!confirm( !confirm(
`本当にカテゴリ「${category}」を${targetText}から削除しますか?\n\n${totalCount}件のアイテムが影響を受けます。この操作は取り消せません。` `本当にカテゴリ「${category}」を${targetText}から削除しますか?\n\n${totalCount}件のアイテムが影響を受けます。この操作は取り消せません。`,
) )
) { ) {
return; return;
} }
try { try {
const res = await fetch(`/api/admin/categories/${encodeURIComponent(category)}`, { const res = await fetch(
method: "DELETE", `/api/admin/categories/${encodeURIComponent(category)}`,
headers: { "Content-Type": "application/json" }, {
body: JSON.stringify({ target }), method: "DELETE",
}); headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target }),
},
);
const data = await res.json(); const data = await res.json();
@ -1230,26 +1281,33 @@ function App() {
</p> </p>
<div style={{ marginBottom: "20px" }}> <div style={{ marginBottom: "20px" }}>
<div className="stats-grid" style={{ gridTemplateColumns: "repeat(4, 1fr)" }}> <div
className="stats-grid"
style={{ gridTemplateColumns: "repeat(4, 1fr)" }}
>
<div className="stat-card"> <div className="stat-card">
<div className="value">{settings.length}</div> <div className="value">{settings.length}</div>
<div className="label"></div> <div className="label"></div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="value"> <div className="value">
{settings.filter(s => s.value !== null && s.value !== "").length} {
settings.filter(
(s) => s.value !== null && s.value !== "",
).length
}
</div> </div>
<div className="label"></div> <div className="label"></div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="value"> <div className="value">
{settings.filter(s => s.required).length} {settings.filter((s) => s.required).length}
</div> </div>
<div className="label"></div> <div className="label"></div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="value"> <div className="value">
{settings.filter(s => s.isCredential).length} {settings.filter((s) => s.isCredential).length}
</div> </div>
<div className="label"></div> <div className="label"></div>
</div> </div>
@ -1258,63 +1316,109 @@ function App() {
<div className="settings-list"> <div className="settings-list">
{settings.map((setting) => ( {settings.map((setting) => (
<div key={setting.key} className="setting-item" style={{ <div
display: "flex", key={setting.key}
alignItems: "center", className="setting-item"
padding: "16px", style={{
border: "1px solid #ddd", display: "flex",
borderRadius: "4px", alignItems: "center",
marginBottom: "12px", padding: "16px",
backgroundColor: setting.required && (!setting.value || setting.value === "") ? "#fff5f5" : "#fff" border: "1px solid #ddd",
}}> borderRadius: "4px",
marginBottom: "12px",
backgroundColor:
setting.required &&
(!setting.value || setting.value === "")
? "#fff5f5"
: "#fff",
}}
>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}> <div
<h4 style={{ margin: 0, fontSize: "16px" }}>{setting.key}</h4> style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "4px",
}}
>
<h4 style={{ margin: 0, fontSize: "16px" }}>
{setting.key}
</h4>
{setting.required && ( {setting.required && (
<span style={{ <span
padding: "2px 6px", style={{
background: "#dc3545", padding: "2px 6px",
color: "white", background: "#dc3545",
borderRadius: "4px", color: "white",
fontSize: "10px" borderRadius: "4px",
}}></span> fontSize: "10px",
}}
>
</span>
)} )}
{setting.isCredential && ( {setting.isCredential && (
<span style={{ <span
padding: "2px 6px", style={{
background: "#ffc107", padding: "2px 6px",
color: "black", background: "#ffc107",
borderRadius: "4px", color: "black",
fontSize: "10px" borderRadius: "4px",
}}></span> fontSize: "10px",
}}
>
</span>
)} )}
</div> </div>
<p style={{ margin: "0 0 8px 0", fontSize: "14px", color: "#666" }}> <p
style={{
margin: "0 0 8px 0",
fontSize: "14px",
color: "#666",
}}
>
{setting.description} {setting.description}
</p> </p>
<div style={{ fontSize: "12px", color: "#999" }}> <div style={{ fontSize: "12px", color: "#999" }}>
: {setting.defaultValue || "なし"} | : {setting.defaultValue || "なし"} |
: {new Date(setting.updatedAt).toLocaleString("ja-JP")} :{" "}
{new Date(setting.updatedAt).toLocaleString("ja-JP")}
</div> </div>
{editingSettings[setting.key] !== undefined ? ( {editingSettings[setting.key] !== undefined ? (
<div style={{ marginTop: "8px", display: "flex", gap: "8px", alignItems: "center" }}> <div
style={{
marginTop: "8px",
display: "flex",
gap: "8px",
alignItems: "center",
}}
>
<input <input
type={setting.isCredential ? "password" : "text"} type={setting.isCredential ? "password" : "text"}
value={editingSettings[setting.key]} value={editingSettings[setting.key]}
onChange={(e) => updateEditingValue(setting.key, e.target.value)} onChange={(e) =>
updateEditingValue(setting.key, e.target.value)
}
placeholder={setting.defaultValue || "値を入力..."} placeholder={setting.defaultValue || "値を入力..."}
style={{ style={{
flex: 1, flex: 1,
padding: "6px 10px", padding: "6px 10px",
border: "1px solid #ddd", border: "1px solid #ddd",
borderRadius: "4px", borderRadius: "4px",
fontSize: "14px" fontSize: "14px",
}} }}
/> />
<button <button
className="btn btn-success" className="btn btn-success"
onClick={() => updateSetting(setting.key, editingSettings[setting.key])} onClick={() =>
updateSetting(
setting.key,
editingSettings[setting.key],
)
}
style={{ fontSize: "12px", padding: "6px 12px" }} style={{ fontSize: "12px", padding: "6px 12px" }}
> >
@ -1328,20 +1432,36 @@ function App() {
</button> </button>
</div> </div>
) : ( ) : (
<div style={{ marginTop: "8px", display: "flex", alignItems: "center", gap: "12px" }}> <div
<div style={{ style={{
flex: 1, marginTop: "8px",
padding: "6px 10px",
background: "#f8f9fa",
border: "1px solid #e9ecef",
borderRadius: "4px",
fontSize: "14px",
minHeight: "32px",
display: "flex", display: "flex",
alignItems: "center" alignItems: "center",
}}> gap: "12px",
}}
>
<div
style={{
flex: 1,
padding: "6px 10px",
background: "#f8f9fa",
border: "1px solid #e9ecef",
borderRadius: "4px",
fontSize: "14px",
minHeight: "32px",
display: "flex",
alignItems: "center",
}}
>
{setting.value === null || setting.value === "" ? ( {setting.value === null || setting.value === "" ? (
<span style={{ color: "#dc3545", fontStyle: "italic" }}></span> <span
style={{
color: "#dc3545",
fontStyle: "italic",
}}
>
</span>
) : setting.isCredential ? ( ) : setting.isCredential ? (
<span style={{ color: "#666" }}></span> <span style={{ color: "#666" }}></span>
) : ( ) : (
@ -1350,7 +1470,9 @@ function App() {
</div> </div>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => startEditingSetting(setting.key, setting.value)} onClick={() =>
startEditingSetting(setting.key, setting.value)
}
style={{ fontSize: "12px", padding: "6px 12px" }} style={{ fontSize: "12px", padding: "6px 12px" }}
> >
@ -1379,10 +1501,18 @@ function App() {
paddingLeft: "20px", paddingLeft: "20px",
}} }}
> >
<li><strong>:</strong> </li> <li>
<li><strong>:</strong> </li> <strong>:</strong>{" "}
</li>
<li>
<strong>:</strong>{" "}
</li>
<li></li> <li></li>
<li>使</li> <li>
使
</li>
</ul> </ul>
</div> </div>
</> </>
@ -1396,17 +1526,26 @@ function App() {
</p> </p>
<div style={{ marginBottom: "20px" }}> <div style={{ marginBottom: "20px" }}>
<div className="stats-grid" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}> <div
className="stats-grid"
style={{ gridTemplateColumns: "repeat(3, 1fr)" }}
>
<div className="stat-card"> <div className="stat-card">
<div className="value">{categories.allCategories.length}</div> <div className="value">
{categories.allCategories.length}
</div>
<div className="label"></div> <div className="label"></div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="value">{categories.feedCategories.length}</div> <div className="value">
{categories.feedCategories.length}
</div>
<div className="label"></div> <div className="label"></div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="value">{categories.episodeCategories.length}</div> <div className="value">
{categories.episodeCategories.length}
</div>
<div className="label"></div> <div className="label"></div>
</div> </div>
</div> </div>
@ -1425,16 +1564,27 @@ function App() {
) : ( ) : (
<div style={{ marginTop: "24px" }}> <div style={{ marginTop: "24px" }}>
<h4> ({categories.allCategories.length})</h4> <h4> ({categories.allCategories.length})</h4>
<div style={{ marginBottom: "16px", fontSize: "14px", color: "#666" }}> <div
style={{
marginBottom: "16px",
fontSize: "14px",
color: "#666",
}}
>
</div> </div>
<div className="category-list"> <div className="category-list">
{categories.allCategories.map((category) => { {categories.allCategories.map((category) => {
const counts = categoryCounts[category] || { feedCount: 0, episodeCount: 0 }; const counts = categoryCounts[category] || {
const isInFeeds = categories.feedCategories.includes(category); feedCount: 0,
const isInEpisodes = categories.episodeCategories.includes(category); episodeCount: 0,
};
const isInFeeds =
categories.feedCategories.includes(category);
const isInEpisodes =
categories.episodeCategories.includes(category);
return ( return (
<div <div
key={category} key={category}
@ -1442,42 +1592,70 @@ function App() {
style={{ marginBottom: "16px" }} style={{ marginBottom: "16px" }}
> >
<div className="feed-info"> <div className="feed-info">
<h4 style={{ margin: "0 0 8px 0", fontSize: "16px" }}> <h4
style={{ margin: "0 0 8px 0", fontSize: "16px" }}
>
{category} {category}
</h4> </h4>
<div style={{ fontSize: "14px", color: "#666", marginBottom: "8px" }}> <div
style={{
fontSize: "14px",
color: "#666",
marginBottom: "8px",
}}
>
<span>: {counts.feedCount}</span> <span>: {counts.feedCount}</span>
<span style={{ margin: "0 12px" }}>|</span> <span style={{ margin: "0 12px" }}>|</span>
<span>: {counts.episodeCount}</span> <span>: {counts.episodeCount}</span>
<span style={{ margin: "0 12px" }}>|</span> <span style={{ margin: "0 12px" }}>|</span>
<span>: {counts.feedCount + counts.episodeCount}</span> <span>
: {counts.feedCount + counts.episodeCount}
</span>
</div> </div>
<div style={{ fontSize: "12px", color: "#999" }}> <div style={{ fontSize: "12px", color: "#999" }}>
<span style={{ <span
padding: "2px 6px", style={{
background: isInFeeds ? "#e3f2fd" : "#f5f5f5", padding: "2px 6px",
color: isInFeeds ? "#1976d2" : "#999", background: isInFeeds ? "#e3f2fd" : "#f5f5f5",
borderRadius: "4px", color: isInFeeds ? "#1976d2" : "#999",
marginRight: "8px" borderRadius: "4px",
}}> marginRight: "8px",
}}
>
: {isInFeeds ? "使用中" : "未使用"} : {isInFeeds ? "使用中" : "未使用"}
</span> </span>
<span style={{ <span
padding: "2px 6px", style={{
background: isInEpisodes ? "#e8f5e8" : "#f5f5f5", padding: "2px 6px",
color: isInEpisodes ? "#388e3c" : "#999", background: isInEpisodes
borderRadius: "4px" ? "#e8f5e8"
}}> : "#f5f5f5",
color: isInEpisodes ? "#388e3c" : "#999",
borderRadius: "4px",
}}
>
: {isInEpisodes ? "使用中" : "未使用"} : {isInEpisodes ? "使用中" : "未使用"}
</span> </span>
</div> </div>
</div> </div>
<div className="feed-actions" style={{ flexDirection: "column", gap: "8px", minWidth: "160px" }}> <div
className="feed-actions"
style={{
flexDirection: "column",
gap: "8px",
minWidth: "160px",
}}
>
{isInFeeds && ( {isInFeeds && (
<button <button
className="btn btn-warning" className="btn btn-warning"
onClick={() => deleteCategory(category, "feeds")} onClick={() =>
style={{ fontSize: "12px", padding: "6px 12px" }} deleteCategory(category, "feeds")
}
style={{
fontSize: "12px",
padding: "6px 12px",
}}
> >
</button> </button>
@ -1485,8 +1663,13 @@ function App() {
{isInEpisodes && ( {isInEpisodes && (
<button <button
className="btn btn-warning" className="btn btn-warning"
onClick={() => deleteCategory(category, "episodes")} onClick={() =>
style={{ fontSize: "12px", padding: "6px 12px" }} deleteCategory(category, "episodes")
}
style={{
fontSize: "12px",
padding: "6px 12px",
}}
> >
</button> </button>
@ -1495,7 +1678,10 @@ function App() {
<button <button
className="btn btn-danger" className="btn btn-danger"
onClick={() => deleteCategory(category, "both")} onClick={() => deleteCategory(category, "both")}
style={{ fontSize: "12px", padding: "6px 12px" }} style={{
fontSize: "12px",
padding: "6px 12px",
}}
> >
</button> </button>
@ -1525,10 +1711,21 @@ function App() {
paddingLeft: "20px", paddingLeft: "20px",
}} }}
> >
<li><strong>:</strong> </li> <li>
<li><strong>:</strong> </li> <strong>:</strong>{" "}
<li><strong>:</strong> </li>
<li> NULL </li> </li>
<li>
<strong>:</strong>{" "}
</li>
<li>
<strong>:</strong>{" "}
</li>
<li>
NULL
</li>
<li></li> <li></li>
</ul> </ul>
</div> </div>

View File

@ -8,17 +8,17 @@ import { batchScheduler } from "./services/batch-scheduler.js";
import { config } from "./services/config.js"; import { config } from "./services/config.js";
import { closeBrowser } from "./services/content-extractor.js"; import { closeBrowser } from "./services/content-extractor.js";
import { import {
deleteCategoryFromBoth,
deleteEpisode, deleteEpisode,
deleteEpisodeCategory,
deleteFeed, deleteFeed,
deleteFeedCategory,
fetchAllEpisodes, fetchAllEpisodes,
fetchEpisodesWithArticles, fetchEpisodesWithArticles,
getAllCategories, getAllCategories,
getAllFeedsIncludingInactive, getAllFeedsIncludingInactive,
getAllUsedCategories, getAllUsedCategories,
getCategoryCounts, getCategoryCounts,
deleteCategoryFromBoth,
deleteFeedCategory,
deleteEpisodeCategory,
getFeedByUrl, getFeedByUrl,
getFeedRequests, getFeedRequests,
getFeedsByCategory, getFeedsByCategory,
@ -375,14 +375,19 @@ app.get("/api/admin/categories/:category/counts", async (c) => {
app.delete("/api/admin/categories/:category", async (c) => { app.delete("/api/admin/categories/:category", async (c) => {
try { try {
const category = decodeURIComponent(c.req.param("category")); const category = decodeURIComponent(c.req.param("category"));
const { target } = await c.req.json<{ target: "feeds" | "episodes" | "both" }>(); const { target } = await c.req.json<{
target: "feeds" | "episodes" | "both";
}>();
if (!category || category.trim() === "") { if (!category || category.trim() === "") {
return c.json({ error: "Category name is required" }, 400); return c.json({ error: "Category name is required" }, 400);
} }
if (!target || !["feeds", "episodes", "both"].includes(target)) { if (!target || !["feeds", "episodes", "both"].includes(target)) {
return c.json({ error: "Valid target (feeds, episodes, or both) is required" }, 400); return c.json(
{ error: "Valid target (feeds, episodes, or both) is required" },
400,
);
} }
console.log(`🗑️ Admin deleting category "${category}" from ${target}`); console.log(`🗑️ Admin deleting category "${category}" from ${target}`);
@ -396,7 +401,7 @@ app.delete("/api/admin/categories/:category", async (c) => {
category, category,
feedChanges: result.feedChanges, feedChanges: result.feedChanges,
episodeChanges: result.episodeChanges, episodeChanges: result.episodeChanges,
totalChanges: result.feedChanges + result.episodeChanges totalChanges: result.feedChanges + result.episodeChanges,
}); });
} else if (target === "feeds") { } else if (target === "feeds") {
const changes = await deleteFeedCategory(category); const changes = await deleteFeedCategory(category);
@ -406,7 +411,7 @@ app.delete("/api/admin/categories/:category", async (c) => {
category, category,
feedChanges: changes, feedChanges: changes,
episodeChanges: 0, episodeChanges: 0,
totalChanges: changes totalChanges: changes,
}); });
} else if (target === "episodes") { } else if (target === "episodes") {
const changes = await deleteEpisodeCategory(category); const changes = await deleteEpisodeCategory(category);
@ -416,7 +421,7 @@ app.delete("/api/admin/categories/:category", async (c) => {
category, category,
feedChanges: 0, feedChanges: 0,
episodeChanges: changes, episodeChanges: changes,
totalChanges: changes totalChanges: changes,
}); });
} }
} catch (error) { } catch (error) {

View File

@ -1,5 +1,5 @@
import { Link, Route, Routes, useLocation } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, Route, Routes, useLocation } from "react-router-dom";
import EpisodeDetail from "./components/EpisodeDetail"; import EpisodeDetail from "./components/EpisodeDetail";
import EpisodeList from "./components/EpisodeList"; import EpisodeList from "./components/EpisodeList";
import FeedDetail from "./components/FeedDetail"; import FeedDetail from "./components/FeedDetail";
@ -10,13 +10,16 @@ import RSSEndpoints from "./components/RSSEndpoints";
function App() { function App() {
const location = useLocation(); const location = useLocation();
const [isDarkMode, setIsDarkMode] = useState(() => { const [isDarkMode, setIsDarkMode] = useState(() => {
const saved = localStorage.getItem('darkMode'); const saved = localStorage.getItem("darkMode");
return saved ? JSON.parse(saved) : false; return saved ? JSON.parse(saved) : false;
}); });
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light'); document.documentElement.setAttribute(
localStorage.setItem('darkMode', JSON.stringify(isDarkMode)); "data-theme",
isDarkMode ? "dark" : "light",
);
localStorage.setItem("darkMode", JSON.stringify(isDarkMode));
}, [isDarkMode]); }, [isDarkMode]);
const toggleDarkMode = () => { const toggleDarkMode = () => {
@ -41,12 +44,12 @@ function App() {
RSS RSS
</div> </div>
</div> </div>
<button <button
className="theme-toggle" className="theme-toggle"
onClick={toggleDarkMode} onClick={toggleDarkMode}
aria-label="テーマを切り替え" aria-label="テーマを切り替え"
> >
{isDarkMode ? '☀️' : '🌙'} {isDarkMode ? "☀️" : "🌙"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -63,7 +63,7 @@ function EpisodeList() {
filterEpisodesByCategory(); filterEpisodesByCategory();
} }
}, [episodes, selectedCategory, searchQuery]); }, [episodes, selectedCategory, searchQuery]);
// Reset to page 1 when category changes (but don't trigger if already on page 1) // Reset to page 1 when category changes (but don't trigger if already on page 1)
useEffect(() => { useEffect(() => {
if (currentPage !== 1) { if (currentPage !== 1) {
@ -85,28 +85,30 @@ function EpisodeList() {
page: currentPage.toString(), page: currentPage.toString(),
limit: pageSize.toString(), limit: pageSize.toString(),
}); });
if (selectedCategory) { if (selectedCategory) {
searchParams.append("category", selectedCategory); searchParams.append("category", selectedCategory);
} }
const response = await fetch(`/api/episodes-with-feed-info?${searchParams}`); const response = await fetch(
`/api/episodes-with-feed-info?${searchParams}`,
);
if (!response.ok) { if (!response.ok) {
throw new Error("データベースからの取得に失敗しました"); throw new Error("データベースからの取得に失敗しました");
} }
const data = await response.json(); const data = await response.json();
// Handle paginated response // Handle paginated response
if (data.episodes !== undefined) { if (data.episodes !== undefined) {
const dbEpisodes = data.episodes || []; const dbEpisodes = data.episodes || [];
if (dbEpisodes.length === 0 && data.total === 0) { if (dbEpisodes.length === 0 && data.total === 0) {
// Database is empty, fallback to XML // Database is empty, fallback to XML
console.log("Database is empty, falling back to XML parsing..."); console.log("Database is empty, falling back to XML parsing...");
setUseDatabase(false); setUseDatabase(false);
return; return;
} }
setEpisodes(dbEpisodes); setEpisodes(dbEpisodes);
setTotalEpisodes(data.total || 0); setTotalEpisodes(data.total || 0);
setTotalPages(data.totalPages || 1); setTotalPages(data.totalPages || 1);
@ -402,9 +404,7 @@ function EpisodeList() {
))} ))}
</select> </select>
)} )}
{isSearching && ( {isSearching && <span className="episode-meta-text">...</span>}
<span className="episode-meta-text">...</span>
)}
</div> </div>
</div> </div>
@ -527,7 +527,7 @@ function EpisodeList() {
))} ))}
</tbody> </tbody>
</table> </table>
{/* Pagination Controls - only show for database mode */} {/* Pagination Controls - only show for database mode */}
{useDatabase && totalPages > 1 && ( {useDatabase && totalPages > 1 && (
<div className="pagination-container"> <div className="pagination-container">
@ -538,7 +538,7 @@ function EpisodeList() {
> >
</button> </button>
{/* Page numbers */} {/* Page numbers */}
<div className="pagination-pages"> <div className="pagination-pages">
{/* First page */} {/* First page */}
@ -553,13 +553,13 @@ function EpisodeList() {
{currentPage > 4 && <span>...</span>} {currentPage > 4 && <span>...</span>}
</> </>
)} )}
{/* Current page and nearby pages */} {/* Current page and nearby pages */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => { {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = Math.max(1, currentPage - 2) + i; const pageNum = Math.max(1, currentPage - 2) + i;
if (pageNum > totalPages) return null; if (pageNum > totalPages) return null;
if (pageNum < Math.max(1, currentPage - 2)) return null; if (pageNum < Math.max(1, currentPage - 2)) return null;
return ( return (
<button <button
key={pageNum} key={pageNum}
@ -570,7 +570,7 @@ function EpisodeList() {
</button> </button>
); );
})} })}
{/* Last page */} {/* Last page */}
{currentPage < totalPages - 2 && ( {currentPage < totalPages - 2 && (
<> <>
@ -584,15 +584,17 @@ function EpisodeList() {
</> </>
)} )}
</div> </div>
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))} onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
> >
</button> </button>
{/* Page size selector */} {/* Page size selector */}
<div className="pagination-size-selector"> <div className="pagination-size-selector">
<span style={{ fontSize: "14px" }}>:</span> <span style={{ fontSize: "14px" }}>:</span>

View File

@ -222,7 +222,10 @@ function FeedDetail() {
<strong> <strong>
<Link <Link
to={`/episode/${episode.id}`} to={`/episode/${episode.id}`}
style={{ textDecoration: "none", color: "var(--accent-primary)" }} style={{
textDecoration: "none",
color: "var(--accent-primary)",
}}
> >
{episode.title} {episode.title}
</Link> </Link>
@ -242,7 +245,10 @@ function FeedDetail() {
href={episode.articleLink} href={episode.articleLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ fontSize: "12px", color: "var(--text-secondary)" }} style={{
fontSize: "12px",
color: "var(--text-secondary)",
}}
> >
</a> </a>

View File

@ -19,7 +19,7 @@ function FeedList() {
const [selectedCategory, setSelectedCategory] = useState<string>(""); const [selectedCategory, setSelectedCategory] = useState<string>("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(15); const [pageSize, setPageSize] = useState(15);
@ -46,7 +46,7 @@ function FeedList() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
// Build query parameters // Build query parameters
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("page", currentPage.toString()); params.append("page", currentPage.toString());
@ -54,17 +54,17 @@ function FeedList() {
if (selectedCategory) { if (selectedCategory) {
params.append("category", selectedCategory); params.append("category", selectedCategory);
} }
const response = await fetch(`/api/feeds?${params.toString()}`); const response = await fetch(`/api/feeds?${params.toString()}`);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.error || "フィードの取得に失敗しました"); throw new Error(errorData.error || "フィードの取得に失敗しました");
} }
const data = await response.json(); const data = await response.json();
// Handle paginated response // Handle paginated response
if (data.feeds && typeof data.total !== 'undefined') { if (data.feeds && typeof data.total !== "undefined") {
setFeeds(data.feeds); setFeeds(data.feeds);
setFilteredFeeds(data.feeds); setFilteredFeeds(data.feeds);
setTotalFeeds(data.total); setTotalFeeds(data.total);
@ -96,7 +96,6 @@ function FeedList() {
} }
}; };
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString("ja-JP"); return new Date(dateString).toLocaleString("ja-JP");
}; };
@ -197,7 +196,11 @@ function FeedList() {
<option value={45}>45</option> <option value={45}>45</option>
<option value={60}>60</option> <option value={60}>60</option>
</select> </select>
<button type="button" className="btn btn-secondary" onClick={fetchFeeds}> <button
type="button"
className="btn btn-secondary"
onClick={fetchFeeds}
>
</button> </button>
</div> </div>
@ -268,7 +271,7 @@ function FeedList() {
> >
</button> </button>
{/* Page numbers */} {/* Page numbers */}
<div className="page-numbers"> <div className="page-numbers">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => { {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
@ -282,12 +285,12 @@ function FeedList() {
} else { } else {
pageNum = currentPage - 2 + i; pageNum = currentPage - 2 + i;
} }
return ( return (
<button <button
type="button" type="button"
key={pageNum} key={pageNum}
className={`btn ${currentPage === pageNum ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${currentPage === pageNum ? "btn-primary" : "btn-secondary"}`}
onClick={() => setCurrentPage(pageNum)} onClick={() => setCurrentPage(pageNum)}
> >
{pageNum} {pageNum}
@ -295,7 +298,7 @@ function FeedList() {
); );
})} })}
</div> </div>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"

View File

@ -512,13 +512,15 @@ app.get("/api/feeds", async (c) => {
const page = c.req.query("page"); const page = c.req.query("page");
const limit = c.req.query("limit"); const limit = c.req.query("limit");
const category = c.req.query("category"); const category = c.req.query("category");
// If pagination parameters are provided, use paginated endpoint // If pagination parameters are provided, use paginated endpoint
if (page || limit) { if (page || limit) {
const { fetchActiveFeedsPaginated } = await import("./services/database.js"); const { fetchActiveFeedsPaginated } = await import(
"./services/database.js"
);
const pageNum = page ? Number.parseInt(page, 10) : 1; const pageNum = page ? Number.parseInt(page, 10) : 1;
const limitNum = limit ? Number.parseInt(limit, 10) : 10; const limitNum = limit ? Number.parseInt(limit, 10) : 10;
// Validate pagination parameters // Validate pagination parameters
if (Number.isNaN(pageNum) || pageNum < 1) { if (Number.isNaN(pageNum) || pageNum < 1) {
return c.json({ error: "Invalid page number" }, 400); return c.json({ error: "Invalid page number" }, 400);
@ -526,8 +528,12 @@ app.get("/api/feeds", async (c) => {
if (Number.isNaN(limitNum) || limitNum < 1 || limitNum > 100) { if (Number.isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
return c.json({ error: "Invalid limit (must be between 1-100)" }, 400); return c.json({ error: "Invalid limit (must be between 1-100)" }, 400);
} }
const result = await fetchActiveFeedsPaginated(pageNum, limitNum, category || undefined); const result = await fetchActiveFeedsPaginated(
pageNum,
limitNum,
category || undefined,
);
return c.json(result); return c.json(result);
} else { } else {
// Original behavior for backward compatibility // Original behavior for backward compatibility
@ -630,13 +636,15 @@ app.get("/api/episodes-with-feed-info", async (c) => {
const page = c.req.query("page"); const page = c.req.query("page");
const limit = c.req.query("limit"); const limit = c.req.query("limit");
const category = c.req.query("category"); const category = c.req.query("category");
// If pagination parameters are provided, use paginated endpoint // If pagination parameters are provided, use paginated endpoint
if (page || limit) { if (page || limit) {
const { fetchEpisodesWithFeedInfoPaginated } = await import("./services/database.js"); const { fetchEpisodesWithFeedInfoPaginated } = await import(
"./services/database.js"
);
const pageNum = page ? Number.parseInt(page, 10) : 1; const pageNum = page ? Number.parseInt(page, 10) : 1;
const limitNum = limit ? Number.parseInt(limit, 10) : 20; const limitNum = limit ? Number.parseInt(limit, 10) : 20;
// Validate pagination parameters // Validate pagination parameters
if (Number.isNaN(pageNum) || pageNum < 1) { if (Number.isNaN(pageNum) || pageNum < 1) {
return c.json({ error: "Invalid page number" }, 400); return c.json({ error: "Invalid page number" }, 400);
@ -644,12 +652,18 @@ app.get("/api/episodes-with-feed-info", async (c) => {
if (Number.isNaN(limitNum) || limitNum < 1 || limitNum > 100) { if (Number.isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
return c.json({ error: "Invalid limit (must be between 1-100)" }, 400); return c.json({ error: "Invalid limit (must be between 1-100)" }, 400);
} }
const result = await fetchEpisodesWithFeedInfoPaginated(pageNum, limitNum, category || undefined); const result = await fetchEpisodesWithFeedInfoPaginated(
pageNum,
limitNum,
category || undefined,
);
return c.json(result); return c.json(result);
} else { } else {
// Original behavior for backward compatibility // Original behavior for backward compatibility
const { fetchEpisodesWithFeedInfo } = await import("./services/database.js"); const { fetchEpisodesWithFeedInfo } = await import(
"./services/database.js"
);
const episodes = await fetchEpisodesWithFeedInfo(); const episodes = await fetchEpisodesWithFeedInfo();
return c.json({ episodes }); return c.json({ episodes });
} }

View File

@ -22,4 +22,4 @@ declare module "kuroshiro-analyzer-mecab" {
} }
export = KuroshiroAnalyzerMecab; export = KuroshiroAnalyzerMecab;
} }