Add category deletion feature
This commit is contained in:
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
|
Reference in New Issue
Block a user