Add file deleting functionality to the admin panel

This commit is contained in:
2025-06-08 17:51:59 +09:00
parent f34c601ec0
commit 023a7ab926
3 changed files with 240 additions and 4 deletions

View File

@ -46,11 +46,30 @@ interface FeedRequest {
adminNotes?: string;
}
interface Episode {
id: string;
title: string;
description?: string;
audioPath: string;
duration?: number;
fileSize?: number;
createdAt: string;
articleId: string;
articleTitle: string;
articleLink: string;
articlePubDate: string;
feedId: string;
feedTitle?: string;
feedUrl: string;
feedCategory?: string;
}
function App() {
const [feeds, setFeeds] = useState<Feed[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [envVars, setEnvVars] = useState<EnvVars>({});
const [feedRequests, setFeedRequests] = useState<FeedRequest[]>([]);
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
@ -62,7 +81,7 @@ function App() {
{},
);
const [activeTab, setActiveTab] = useState<
"dashboard" | "feeds" | "env" | "batch" | "requests"
"dashboard" | "feeds" | "episodes" | "env" | "batch" | "requests"
>("dashboard");
useEffect(() => {
@ -72,28 +91,31 @@ function App() {
const loadData = async () => {
setLoading(true);
try {
const [feedsRes, statsRes, envRes, requestsRes] = await Promise.all([
const [feedsRes, statsRes, envRes, requestsRes, episodesRes] = await Promise.all([
fetch("/api/admin/feeds"),
fetch("/api/admin/stats"),
fetch("/api/admin/env"),
fetch("/api/admin/feed-requests"),
fetch("/api/admin/episodes"),
]);
if (!feedsRes.ok || !statsRes.ok || !envRes.ok || !requestsRes.ok) {
if (!feedsRes.ok || !statsRes.ok || !envRes.ok || !requestsRes.ok || !episodesRes.ok) {
throw new Error("Failed to load data");
}
const [feedsData, statsData, envData, requestsData] = await Promise.all([
const [feedsData, statsData, envData, requestsData, episodesData] = await Promise.all([
feedsRes.json(),
statsRes.json(),
envRes.json(),
requestsRes.json(),
episodesRes.json(),
]);
setFeeds(feedsData);
setStats(statsData);
setEnvVars(envData);
setFeedRequests(requestsData);
setEpisodes(episodesData);
setError(null);
} catch (err) {
setError("データの読み込みに失敗しました");
@ -308,6 +330,34 @@ function App() {
setApprovalNotes({ ...approvalNotes, [requestId]: notes });
};
const deleteEpisode = async (episodeId: string, episodeTitle: string) => {
if (
!confirm(
`本当にエピソード「${episodeTitle}」を削除しますか?\n\n音声ファイルも削除され、この操作は取り消せません。`,
)
) {
return;
}
try {
const res = await fetch(`/api/admin/episodes/${episodeId}`, {
method: "DELETE",
});
const data = await res.json();
if (res.ok) {
setSuccess(data.message);
loadData();
} else {
setError(data.error || "エピソード削除に失敗しました");
}
} catch (err) {
setError("エピソード削除に失敗しました");
console.error("Error deleting episode:", err);
}
};
const filteredRequests = feedRequests.filter((request) => {
if (requestFilter === "all") return true;
return request.status === requestFilter;
@ -348,6 +398,12 @@ function App() {
>
</button>
<button
className={`btn ${activeTab === "episodes" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("episodes")}
>
</button>
<button
className={`btn ${activeTab === "batch" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("batch")}
@ -516,6 +572,122 @@ function App() {
</>
)}
{activeTab === "episodes" && (
<>
<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">{episodes.length}</div>
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value">
{episodes.filter(ep => ep.feedCategory).map(ep => ep.feedCategory).filter((category, index, arr) => arr.indexOf(category) === index).length}
</div>
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value">
{episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / (1024 * 1024) > 1
? `${Math.round(episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / (1024 * 1024))}MB`
: `${Math.round(episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / 1024)}KB`}
</div>
<div className="label"></div>
</div>
</div>
</div>
{episodes.length === 0 ? (
<p
style={{
color: "#7f8c8d",
textAlign: "center",
padding: "20px",
}}
>
</p>
) : (
<div style={{ marginTop: "24px" }}>
<h4> ({episodes.length})</h4>
<ul className="feeds-list">
{episodes.map((episode) => (
<li key={episode.id} className="feed-item">
<div className="feed-info">
<h3>{episode.title}</h3>
<div className="url" style={{ fontSize: "14px", color: "#666" }}>
: {episode.feedTitle || episode.feedUrl}
{episode.feedCategory && (
<span style={{ marginLeft: "8px", padding: "2px 6px", background: "#e9ecef", borderRadius: "4px", fontSize: "12px" }}>
{episode.feedCategory}
</span>
)}
</div>
<div className="url" style={{ fontSize: "14px", color: "#666" }}>
: <a href={episode.articleLink} target="_blank" rel="noopener noreferrer">{episode.articleTitle}</a>
</div>
{episode.description && (
<div style={{ fontSize: "14px", color: "#777", marginTop: "4px" }}>
{episode.description.length > 100
? episode.description.substring(0, 100) + "..."
: episode.description}
</div>
)}
<div style={{ fontSize: "12px", color: "#999", marginTop: "8px" }}>
<span>: {new Date(episode.createdAt).toLocaleString("ja-JP")}</span>
{episode.duration && (
<>
<span style={{ margin: "0 8px" }}>|</span>
<span>: {Math.round(episode.duration / 60)}</span>
</>
)}
{episode.fileSize && (
<>
<span style={{ margin: "0 8px" }}>|</span>
<span>
: {episode.fileSize > 1024 * 1024
? `${Math.round(episode.fileSize / (1024 * 1024))}MB`
: `${Math.round(episode.fileSize / 1024)}KB`}
</span>
</>
)}
</div>
<div style={{ marginTop: "8px" }}>
<a
href={episode.audioPath}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: "12px",
color: "#007bff",
textDecoration: "none"
}}
>
🎵
</a>
</div>
</div>
<div className="feed-actions">
<button
className="btn btn-danger"
onClick={() => deleteEpisode(episode.id, episode.title)}
>
</button>
</div>
</li>
))}
</ul>
</div>
)}
</>
)}
{activeTab === "batch" && (
<>
<h3></h3>

View File

@ -8,6 +8,7 @@ import { batchScheduler } from "./services/batch-scheduler.js";
import { config, validateConfig } from "./services/config.js";
import {
deleteFeed,
deleteEpisode,
fetchAllEpisodes,
fetchEpisodesWithArticles,
getAllCategories,
@ -254,6 +255,33 @@ app.get("/api/admin/episodes/simple", async (c) => {
}
});
app.delete("/api/admin/episodes/:id", async (c) => {
try {
const episodeId = c.req.param("id");
if (!episodeId || episodeId.trim() === "") {
return c.json({ error: "Episode ID is required" }, 400);
}
console.log("🗑️ Admin deleting episode ID:", episodeId);
const deleted = await deleteEpisode(episodeId);
if (deleted) {
return c.json({
result: "DELETED",
message: "Episode deleted successfully",
episodeId,
});
} else {
return c.json({ error: "Episode not found" }, 404);
}
} catch (error) {
console.error("Error deleting episode:", error);
return c.json({ error: "Failed to delete episode" }, 500);
}
});
// Database diagnostic endpoint
app.get("/api/admin/db-diagnostic", async (c) => {
try {

View File

@ -1,6 +1,7 @@
import { Database } from "bun:sqlite";
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { config } from "./config.js";
// Database integrity fixes function
@ -1208,6 +1209,41 @@ export async function getFeedCategoryMigrationStatus(): Promise<{
}
}
export async function deleteEpisode(episodeId: string): Promise<boolean> {
try {
// Get episode info first to find the audio file path
const episodeStmt = db.prepare("SELECT audio_path FROM episodes WHERE id = ?");
const episode = episodeStmt.get(episodeId) as any;
if (!episode) {
return false; // Episode not found
}
// Delete from database
const deleteStmt = db.prepare("DELETE FROM episodes WHERE id = ?");
const result = deleteStmt.run(episodeId);
// If database deletion successful, try to delete the audio file
if (result.changes > 0 && episode.audio_path) {
try {
const fullAudioPath = path.join(config.paths.projectRoot, episode.audio_path);
if (fs.existsSync(fullAudioPath)) {
fs.unlinkSync(fullAudioPath);
console.log(`🗑️ Deleted audio file: ${fullAudioPath}`);
}
} catch (fileError) {
console.warn(`⚠️ Failed to delete audio file ${episode.audio_path}:`, fileError);
// Don't fail the operation if file deletion fails
}
}
return result.changes > 0;
} catch (error) {
console.error("Error deleting episode:", error);
throw error;
}
}
export function closeDatabase(): void {
db.close();
}