Add category deletion feature

This commit is contained in:
2025-06-09 16:02:26 +09:00
parent d21c2356f3
commit 704df2774f
4 changed files with 407 additions and 8 deletions

View File

@ -74,6 +74,17 @@ interface Setting {
updatedAt: string;
}
interface CategoryData {
feedCategories: string[];
episodeCategories: string[];
allCategories: string[];
}
interface CategoryCounts {
feedCount: number;
episodeCount: number;
}
function App() {
const [feeds, setFeeds] = useState<Feed[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
@ -92,8 +103,10 @@ function App() {
{},
);
const [editingSettings, setEditingSettings] = useState<{ [key: string]: string }>({});
const [categories, setCategories] = useState<CategoryData>({ feedCategories: [], episodeCategories: [], allCategories: [] });
const [categoryCounts, setCategoryCounts] = useState<{ [category: string]: CategoryCounts }>({});
const [activeTab, setActiveTab] = useState<
"dashboard" | "feeds" | "episodes" | "env" | "settings" | "batch" | "requests"
"dashboard" | "feeds" | "episodes" | "env" | "settings" | "batch" | "requests" | "categories"
>("dashboard");
useEffect(() => {
@ -103,7 +116,7 @@ function App() {
const loadData = async () => {
setLoading(true);
try {
const [feedsRes, statsRes, envRes, settingsRes, requestsRes, episodesRes] =
const [feedsRes, statsRes, envRes, settingsRes, requestsRes, episodesRes, categoriesRes] =
await Promise.all([
fetch("/api/admin/feeds"),
fetch("/api/admin/stats"),
@ -111,6 +124,7 @@ function App() {
fetch("/api/admin/settings"),
fetch("/api/admin/feed-requests"),
fetch("/api/admin/episodes"),
fetch("/api/admin/categories/all"),
]);
if (
@ -119,12 +133,13 @@ function App() {
!envRes.ok ||
!settingsRes.ok ||
!requestsRes.ok ||
!episodesRes.ok
!episodesRes.ok ||
!categoriesRes.ok
) {
throw new Error("Failed to load data");
}
const [feedsData, statsData, envData, settingsData, requestsData, episodesData] =
const [feedsData, statsData, envData, settingsData, requestsData, episodesData, categoriesData] =
await Promise.all([
feedsRes.json(),
statsRes.json(),
@ -132,6 +147,7 @@ function App() {
settingsRes.json(),
requestsRes.json(),
episodesRes.json(),
categoriesRes.json(),
]);
setFeeds(feedsData);
@ -140,6 +156,25 @@ function App() {
setSettings(settingsData);
setFeedRequests(requestsData);
setEpisodes(episodesData);
setCategories(categoriesData);
// Load category counts for all categories
const countsPromises = categoriesData.allCategories.map(async (category: string) => {
const res = await fetch(`/api/admin/categories/${encodeURIComponent(category)}/counts`);
if (res.ok) {
const counts = await res.json();
return { category, counts };
}
return { category, counts: { feedCount: 0, episodeCount: 0 } };
});
const countsResults = await Promise.all(countsPromises);
const countsMap: { [category: string]: CategoryCounts } = {};
countsResults.forEach(({ category, counts }) => {
countsMap[category] = counts;
});
setCategoryCounts(countsMap);
setError(null);
} catch (err) {
setError("データの読み込みに失敗しました");
@ -391,6 +426,41 @@ function App() {
setEditingSettings({ ...editingSettings, [key]: value });
};
const deleteCategory = async (category: string, target: "feeds" | "episodes" | "both") => {
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 (
!confirm(
`本当にカテゴリ「${category}」を${targetText}から削除しますか?\n\n${totalCount}件のアイテムが影響を受けます。この操作は取り消せません。`
)
) {
return;
}
try {
const res = await fetch(`/api/admin/categories/${encodeURIComponent(category)}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target }),
});
const data = await res.json();
if (res.ok) {
setSuccess(data.message);
loadData(); // Reload data to update category list and counts
} else {
setError(data.error || "カテゴリ削除に失敗しました");
}
} catch (err) {
setError("カテゴリ削除に失敗しました");
console.error("Error deleting category:", err);
}
};
const deleteEpisode = async (episodeId: string, episodeTitle: string) => {
if (
!confirm(
@ -483,6 +553,12 @@ function App() {
>
</button>
<button
className={`btn ${activeTab === "categories" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("categories")}
>
</button>
<button
className={`btn ${activeTab === "env" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("env")}
@ -1312,6 +1388,153 @@ function App() {
</>
)}
{activeTab === "categories" && (
<>
<h3></h3>
<p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
</p>
<div style={{ marginBottom: "20px" }}>
<div className="stats-grid" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}>
<div className="stat-card">
<div className="value">{categories.allCategories.length}</div>
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value">{categories.feedCategories.length}</div>
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value">{categories.episodeCategories.length}</div>
<div className="label"></div>
</div>
</div>
</div>
{categories.allCategories.length === 0 ? (
<p
style={{
color: "#7f8c8d",
textAlign: "center",
padding: "20px",
}}
>
</p>
) : (
<div style={{ marginTop: "24px" }}>
<h4> ({categories.allCategories.length})</h4>
<div style={{ marginBottom: "16px", fontSize: "14px", color: "#666" }}>
</div>
<div className="category-list">
{categories.allCategories.map((category) => {
const counts = categoryCounts[category] || { feedCount: 0, episodeCount: 0 };
const isInFeeds = categories.feedCategories.includes(category);
const isInEpisodes = categories.episodeCategories.includes(category);
return (
<div
key={category}
className="feed-item"
style={{ marginBottom: "16px" }}
>
<div className="feed-info">
<h4 style={{ margin: "0 0 8px 0", fontSize: "16px" }}>
{category}
</h4>
<div style={{ fontSize: "14px", color: "#666", marginBottom: "8px" }}>
<span>: {counts.feedCount}</span>
<span style={{ margin: "0 12px" }}>|</span>
<span>: {counts.episodeCount}</span>
<span style={{ margin: "0 12px" }}>|</span>
<span>: {counts.feedCount + counts.episodeCount}</span>
</div>
<div style={{ fontSize: "12px", color: "#999" }}>
<span style={{
padding: "2px 6px",
background: isInFeeds ? "#e3f2fd" : "#f5f5f5",
color: isInFeeds ? "#1976d2" : "#999",
borderRadius: "4px",
marginRight: "8px"
}}>
: {isInFeeds ? "使用中" : "未使用"}
</span>
<span style={{
padding: "2px 6px",
background: isInEpisodes ? "#e8f5e8" : "#f5f5f5",
color: isInEpisodes ? "#388e3c" : "#999",
borderRadius: "4px"
}}>
: {isInEpisodes ? "使用中" : "未使用"}
</span>
</div>
</div>
<div className="feed-actions" style={{ flexDirection: "column", gap: "8px", minWidth: "160px" }}>
{isInFeeds && (
<button
className="btn btn-warning"
onClick={() => deleteCategory(category, "feeds")}
style={{ fontSize: "12px", padding: "6px 12px" }}
>
</button>
)}
{isInEpisodes && (
<button
className="btn btn-warning"
onClick={() => deleteCategory(category, "episodes")}
style={{ fontSize: "12px", padding: "6px 12px" }}
>
</button>
)}
{(isInFeeds || isInEpisodes) && (
<button
className="btn btn-danger"
onClick={() => deleteCategory(category, "both")}
style={{ fontSize: "12px", padding: "6px 12px" }}
>
</button>
)}
</div>
</div>
);
})}
</div>
</div>
)}
<div
style={{
marginTop: "20px",
padding: "16px",
background: "#f8f9fa",
borderRadius: "4px",
}}
>
<h4></h4>
<ul
style={{
fontSize: "14px",
color: "#6c757d",
marginTop: "8px",
paddingLeft: "20px",
}}
>
<li><strong>:</strong> </li>
<li><strong>:</strong> </li>
<li><strong>:</strong> </li>
<li> NULL </li>
<li></li>
</ul>
</div>
</>
)}
{activeTab === "env" && (
<>
<h3></h3>

View File

@ -14,6 +14,11 @@ import {
fetchEpisodesWithArticles,
getAllCategories,
getAllFeedsIncludingInactive,
getAllUsedCategories,
getCategoryCounts,
deleteCategoryFromBoth,
deleteFeedCategory,
deleteEpisodeCategory,
getFeedByUrl,
getFeedRequests,
getFeedsByCategory,
@ -345,6 +350,81 @@ app.delete("/api/admin/episodes/:id", async (c) => {
}
});
// Category management API endpoints
app.get("/api/admin/categories/all", async (c) => {
try {
const categories = await getAllUsedCategories();
return c.json(categories);
} catch (error) {
console.error("Error fetching all used categories:", error);
return c.json({ error: "Failed to fetch categories" }, 500);
}
});
app.get("/api/admin/categories/:category/counts", async (c) => {
try {
const category = decodeURIComponent(c.req.param("category"));
const counts = await getCategoryCounts(category);
return c.json(counts);
} catch (error) {
console.error("Error fetching category counts:", error);
return c.json({ error: "Failed to fetch category counts" }, 500);
}
});
app.delete("/api/admin/categories/:category", async (c) => {
try {
const category = decodeURIComponent(c.req.param("category"));
const { target } = await c.req.json<{ target: "feeds" | "episodes" | "both" }>();
if (!category || category.trim() === "") {
return c.json({ error: "Category name is required" }, 400);
}
if (!target || !["feeds", "episodes", "both"].includes(target)) {
return c.json({ error: "Valid target (feeds, episodes, or both) is required" }, 400);
}
console.log(`🗑️ Admin deleting category "${category}" from ${target}`);
let result;
if (target === "both") {
result = await deleteCategoryFromBoth(category);
return c.json({
result: "DELETED",
message: `Category "${category}" deleted from feeds and episodes`,
category,
feedChanges: result.feedChanges,
episodeChanges: result.episodeChanges,
totalChanges: result.feedChanges + result.episodeChanges
});
} else if (target === "feeds") {
const changes = await deleteFeedCategory(category);
return c.json({
result: "DELETED",
message: `Category "${category}" deleted from feeds`,
category,
feedChanges: changes,
episodeChanges: 0,
totalChanges: changes
});
} else if (target === "episodes") {
const changes = await deleteEpisodeCategory(category);
return c.json({
result: "DELETED",
message: `Category "${category}" deleted from episodes`,
category,
feedChanges: 0,
episodeChanges: changes,
totalChanges: changes
});
}
} catch (error) {
console.error("Error deleting category:", error);
return c.json({ error: "Failed to delete category" }, 500);
}
});
// Database diagnostic endpoint
app.get("/api/admin/db-diagnostic", async (c) => {
try {

View File

@ -1633,6 +1633,97 @@ export async function updateEpisodeCategory(
}
}
// Category cleanup functions
export async function deleteFeedCategory(category: string): Promise<number> {
try {
const stmt = db.prepare("UPDATE feeds SET category = NULL WHERE category = ?");
const result = stmt.run(category);
return result.changes;
} catch (error) {
console.error("Error deleting feed category:", error);
throw error;
}
}
export async function deleteEpisodeCategory(category: string): Promise<number> {
try {
const stmt = db.prepare("UPDATE episodes SET category = NULL WHERE category = ?");
const result = stmt.run(category);
return result.changes;
} catch (error) {
console.error("Error deleting episode category:", error);
throw error;
}
}
export async function deleteCategoryFromBoth(category: string): Promise<{feedChanges: number, episodeChanges: number}> {
try {
db.exec("BEGIN TRANSACTION");
const feedChanges = await deleteFeedCategory(category);
const episodeChanges = await deleteEpisodeCategory(category);
db.exec("COMMIT");
return { feedChanges, episodeChanges };
} catch (error) {
db.exec("ROLLBACK");
console.error("Error deleting category from both tables:", error);
throw error;
}
}
export async function getAllUsedCategories(): Promise<{feedCategories: string[], episodeCategories: string[], allCategories: string[]}> {
try {
// Get feed categories
const feedCatStmt = db.prepare(
"SELECT DISTINCT category FROM feeds WHERE category IS NOT NULL AND category != '' ORDER BY category"
);
const feedCatRows = feedCatStmt.all() as any[];
const feedCategories = feedCatRows.map(row => row.category);
// Get episode categories
const episodeCatStmt = db.prepare(
"SELECT DISTINCT category FROM episodes WHERE category IS NOT NULL AND category != '' ORDER BY category"
);
const episodeCatRows = episodeCatStmt.all() as any[];
const episodeCategories = episodeCatRows.map(row => row.category);
// Get all unique categories
const allCategoriesSet = new Set([...feedCategories, ...episodeCategories]);
const allCategories = Array.from(allCategoriesSet).sort();
return {
feedCategories,
episodeCategories,
allCategories
};
} catch (error) {
console.error("Error getting all used categories:", error);
throw error;
}
}
export async function getCategoryCounts(category: string): Promise<{feedCount: number, episodeCount: number}> {
try {
// Count feeds with this category
const feedCountStmt = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE category = ?");
const feedCountResult = feedCountStmt.get(category) as { count: number };
// Count episodes with this category
const episodeCountStmt = db.prepare("SELECT COUNT(*) as count FROM episodes WHERE category = ?");
const episodeCountResult = episodeCountStmt.get(category) as { count: number };
return {
feedCount: feedCountResult.count,
episodeCount: episodeCountResult.count
};
} catch (error) {
console.error("Error getting category counts:", error);
throw error;
}
}
// Migration function to classify existing episodes without categories
export async function migrateEpisodesWithCategories(): Promise<void> {
try {

View File

@ -127,7 +127,10 @@ ${articleDetails}
try {
const response = await openai.chat.completions.create({
model: config.openai.modelName,
messages: [{ role: "system", content: prompt.trim() }, {role:"user", content: sendContent.trim()}],
messages: [
{ role: "system", content: prompt.trim() },
{ role: "user", content: sendContent.trim() },
],
temperature: 0.6,
});
@ -171,7 +174,9 @@ export async function openAI_ClassifyEpisode(
}
const prompt = `
ポッドキャストエピソードの情報を見て、適切なトピックカテゴリに分類してください。
以下のポッドキャストエピソードの情報を見て、適切なトピックカテゴリに分類してください。
${textForClassification}
以下のカテゴリから1つを選択してください:
- テクノロジー
@ -191,8 +196,8 @@ export async function openAI_ClassifyEpisode(
try {
const response = await openai.chat.completions.create({
model: config.openai.modelName,
messages: [{ role: "system", content: prompt.trim() }, {role: "user", content: textForClassification.trim()}],
temperature: 0.3,
messages: [{ role: "user", content: prompt.trim() }],
temperature: 0.2,
});
const category = response.choices[0]?.message?.content?.trim();