Add file deleting functionality to the admin panel
This commit is contained in:
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
}
|
||||
|
Reference in New Issue
Block a user