Add file deleting functionality to the admin panel
This commit is contained in:
@ -46,11 +46,30 @@ interface FeedRequest {
|
|||||||
adminNotes?: string;
|
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() {
|
function App() {
|
||||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [envVars, setEnvVars] = useState<EnvVars>({});
|
const [envVars, setEnvVars] = useState<EnvVars>({});
|
||||||
const [feedRequests, setFeedRequests] = useState<FeedRequest[]>([]);
|
const [feedRequests, setFeedRequests] = useState<FeedRequest[]>([]);
|
||||||
|
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
@ -62,7 +81,7 @@ function App() {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"dashboard" | "feeds" | "env" | "batch" | "requests"
|
"dashboard" | "feeds" | "episodes" | "env" | "batch" | "requests"
|
||||||
>("dashboard");
|
>("dashboard");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -72,28 +91,31 @@ function App() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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/feeds"),
|
||||||
fetch("/api/admin/stats"),
|
fetch("/api/admin/stats"),
|
||||||
fetch("/api/admin/env"),
|
fetch("/api/admin/env"),
|
||||||
fetch("/api/admin/feed-requests"),
|
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");
|
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(),
|
feedsRes.json(),
|
||||||
statsRes.json(),
|
statsRes.json(),
|
||||||
envRes.json(),
|
envRes.json(),
|
||||||
requestsRes.json(),
|
requestsRes.json(),
|
||||||
|
episodesRes.json(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setFeeds(feedsData);
|
setFeeds(feedsData);
|
||||||
setStats(statsData);
|
setStats(statsData);
|
||||||
setEnvVars(envData);
|
setEnvVars(envData);
|
||||||
setFeedRequests(requestsData);
|
setFeedRequests(requestsData);
|
||||||
|
setEpisodes(episodesData);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("データの読み込みに失敗しました");
|
setError("データの読み込みに失敗しました");
|
||||||
@ -308,6 +330,34 @@ function App() {
|
|||||||
setApprovalNotes({ ...approvalNotes, [requestId]: notes });
|
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) => {
|
const filteredRequests = feedRequests.filter((request) => {
|
||||||
if (requestFilter === "all") return true;
|
if (requestFilter === "all") return true;
|
||||||
return request.status === requestFilter;
|
return request.status === requestFilter;
|
||||||
@ -348,6 +398,12 @@ function App() {
|
|||||||
>
|
>
|
||||||
フィード管理
|
フィード管理
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn ${activeTab === "episodes" ? "btn-primary" : "btn-secondary"}`}
|
||||||
|
onClick={() => setActiveTab("episodes")}
|
||||||
|
>
|
||||||
|
エピソード管理
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`btn ${activeTab === "batch" ? "btn-primary" : "btn-secondary"}`}
|
className={`btn ${activeTab === "batch" ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setActiveTab("batch")}
|
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" && (
|
{activeTab === "batch" && (
|
||||||
<>
|
<>
|
||||||
<h3>バッチ処理管理</h3>
|
<h3>バッチ処理管理</h3>
|
||||||
|
@ -8,6 +8,7 @@ import { batchScheduler } from "./services/batch-scheduler.js";
|
|||||||
import { config, validateConfig } from "./services/config.js";
|
import { config, validateConfig } from "./services/config.js";
|
||||||
import {
|
import {
|
||||||
deleteFeed,
|
deleteFeed,
|
||||||
|
deleteEpisode,
|
||||||
fetchAllEpisodes,
|
fetchAllEpisodes,
|
||||||
fetchEpisodesWithArticles,
|
fetchEpisodesWithArticles,
|
||||||
getAllCategories,
|
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
|
// Database diagnostic endpoint
|
||||||
app.get("/api/admin/db-diagnostic", async (c) => {
|
app.get("/api/admin/db-diagnostic", async (c) => {
|
||||||
try {
|
try {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
|
|
||||||
// Database integrity fixes function
|
// 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 {
|
export function closeDatabase(): void {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user