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