Update
This commit is contained in:
		@@ -1,4 +1,5 @@
 | 
				
			|||||||
import { Link, Route, Routes, useLocation } from "react-router-dom";
 | 
					import { Link, Route, Routes, useLocation } from "react-router-dom";
 | 
				
			||||||
 | 
					import CategoryList from "./components/CategoryList";
 | 
				
			||||||
import EpisodeDetail from "./components/EpisodeDetail";
 | 
					import EpisodeDetail from "./components/EpisodeDetail";
 | 
				
			||||||
import EpisodeList from "./components/EpisodeList";
 | 
					import EpisodeList from "./components/EpisodeList";
 | 
				
			||||||
import FeedDetail from "./components/FeedDetail";
 | 
					import FeedDetail from "./components/FeedDetail";
 | 
				
			||||||
@@ -7,7 +8,7 @@ import FeedManager from "./components/FeedManager";
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
function App() {
 | 
					function App() {
 | 
				
			||||||
  const location = useLocation();
 | 
					  const location = useLocation();
 | 
				
			||||||
  const isMainPage = ["/", "/feeds", "/feed-requests"].includes(
 | 
					  const isMainPage = ["/", "/feeds", "/categories", "/feed-requests"].includes(
 | 
				
			||||||
    location.pathname,
 | 
					    location.pathname,
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -35,6 +36,12 @@ function App() {
 | 
				
			|||||||
            >
 | 
					            >
 | 
				
			||||||
              フィード一覧
 | 
					              フィード一覧
 | 
				
			||||||
            </Link>
 | 
					            </Link>
 | 
				
			||||||
 | 
					            <Link
 | 
				
			||||||
 | 
					              to="/categories"
 | 
				
			||||||
 | 
					              className={`tab ${location.pathname === "/categories" ? "active" : ""}`}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              カテゴリ一覧
 | 
				
			||||||
 | 
					            </Link>
 | 
				
			||||||
            <Link
 | 
					            <Link
 | 
				
			||||||
              to="/feed-requests"
 | 
					              to="/feed-requests"
 | 
				
			||||||
              className={`tab ${location.pathname === "/feed-requests" ? "active" : ""}`}
 | 
					              className={`tab ${location.pathname === "/feed-requests" ? "active" : ""}`}
 | 
				
			||||||
@@ -49,6 +56,7 @@ function App() {
 | 
				
			|||||||
        <Routes>
 | 
					        <Routes>
 | 
				
			||||||
          <Route path="/" element={<EpisodeList />} />
 | 
					          <Route path="/" element={<EpisodeList />} />
 | 
				
			||||||
          <Route path="/feeds" element={<FeedList />} />
 | 
					          <Route path="/feeds" element={<FeedList />} />
 | 
				
			||||||
 | 
					          <Route path="/categories" element={<CategoryList />} />
 | 
				
			||||||
          <Route path="/feed-requests" element={<FeedManager />} />
 | 
					          <Route path="/feed-requests" element={<FeedManager />} />
 | 
				
			||||||
          <Route path="/episode/:episodeId" element={<EpisodeDetail />} />
 | 
					          <Route path="/episode/:episodeId" element={<EpisodeDetail />} />
 | 
				
			||||||
          <Route path="/feeds/:feedId" element={<FeedDetail />} />
 | 
					          <Route path="/feeds/:feedId" element={<FeedDetail />} />
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										397
									
								
								frontend/src/components/CategoryList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										397
									
								
								frontend/src/components/CategoryList.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,397 @@
 | 
				
			|||||||
 | 
					import { useEffect, useState } from "react";
 | 
				
			||||||
 | 
					import { Link } from "react-router-dom";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Feed {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  url: string;
 | 
				
			||||||
 | 
					  title?: string;
 | 
				
			||||||
 | 
					  description?: string;
 | 
				
			||||||
 | 
					  category?: string;
 | 
				
			||||||
 | 
					  lastUpdated?: string;
 | 
				
			||||||
 | 
					  createdAt: string;
 | 
				
			||||||
 | 
					  active: boolean;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface CategoryGroup {
 | 
				
			||||||
 | 
					  [category: string]: Feed[];
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function CategoryList() {
 | 
				
			||||||
 | 
					  const [groupedFeeds, setGroupedFeeds] = useState<CategoryGroup>({});
 | 
				
			||||||
 | 
					  const [categories, setCategories] = useState<string[]>([]);
 | 
				
			||||||
 | 
					  const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
 | 
				
			||||||
 | 
					  const [filteredFeeds, setFilteredFeeds] = useState<Feed[]>([]);
 | 
				
			||||||
 | 
					  const [loading, setLoading] = useState(true);
 | 
				
			||||||
 | 
					  const [error, setError] = useState<string | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    fetchCategoriesAndFeeds();
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (selectedCategory && groupedFeeds[selectedCategory]) {
 | 
				
			||||||
 | 
					      setFilteredFeeds(groupedFeeds[selectedCategory]);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      setFilteredFeeds([]);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [selectedCategory, groupedFeeds]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const fetchCategoriesAndFeeds = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setLoading(true);
 | 
				
			||||||
 | 
					      setError(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Fetch grouped feeds
 | 
				
			||||||
 | 
					      const [groupedResponse, categoriesResponse] = await Promise.all([
 | 
				
			||||||
 | 
					        fetch("/api/feeds/grouped-by-category"),
 | 
				
			||||||
 | 
					        fetch("/api/categories"),
 | 
				
			||||||
 | 
					      ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!groupedResponse.ok || !categoriesResponse.ok) {
 | 
				
			||||||
 | 
					        throw new Error("カテゴリデータの取得に失敗しました");
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const groupedData = await groupedResponse.json();
 | 
				
			||||||
 | 
					      const categoriesData = await categoriesResponse.json();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      setGroupedFeeds(groupedData.groupedFeeds || {});
 | 
				
			||||||
 | 
					      setCategories(categoriesData.categories || []);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      console.error("Category fetch error:", err);
 | 
				
			||||||
 | 
					      setError(err instanceof Error ? err.message : "エラーが発生しました");
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setLoading(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const formatDate = (dateString: string) => {
 | 
				
			||||||
 | 
					    return new Date(dateString).toLocaleString("ja-JP");
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const getFeedCount = (category: string) => {
 | 
				
			||||||
 | 
					    return groupedFeeds[category]?.length || 0;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const getTotalEpisodesCount = (feeds: Feed[]) => {
 | 
				
			||||||
 | 
					    // This would require an additional API call to get episode counts per feed
 | 
				
			||||||
 | 
					    // For now, just return the feed count
 | 
				
			||||||
 | 
					    return feeds.length;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (loading) {
 | 
				
			||||||
 | 
					    return <div className="loading">読み込み中...</div>;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (error) {
 | 
				
			||||||
 | 
					    return <div className="error">{error}</div>;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const availableCategories = Object.keys(groupedFeeds).filter(
 | 
				
			||||||
 | 
					    (category) => groupedFeeds[category] && groupedFeeds[category].length > 0,
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (availableCategories.length === 0) {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <div className="empty-state">
 | 
				
			||||||
 | 
					        <p>カテゴリ別のフィードがありません</p>
 | 
				
			||||||
 | 
					        <p>
 | 
				
			||||||
 | 
					          フィードにカテゴリが設定されていないか、アクティブなフィードがない可能性があります
 | 
				
			||||||
 | 
					        </p>
 | 
				
			||||||
 | 
					        <button
 | 
				
			||||||
 | 
					          className="btn btn-secondary"
 | 
				
			||||||
 | 
					          onClick={fetchCategoriesAndFeeds}
 | 
				
			||||||
 | 
					          style={{ marginTop: "10px" }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          再読み込み
 | 
				
			||||||
 | 
					        </button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div>
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        style={{
 | 
				
			||||||
 | 
					          marginBottom: "20px",
 | 
				
			||||||
 | 
					          display: "flex",
 | 
				
			||||||
 | 
					          justifyContent: "space-between",
 | 
				
			||||||
 | 
					          alignItems: "center",
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <h2>カテゴリ一覧 ({availableCategories.length}件)</h2>
 | 
				
			||||||
 | 
					        <button className="btn btn-secondary" onClick={fetchCategoriesAndFeeds}>
 | 
				
			||||||
 | 
					          更新
 | 
				
			||||||
 | 
					        </button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {!selectedCategory ? (
 | 
				
			||||||
 | 
					        <div className="category-grid">
 | 
				
			||||||
 | 
					          {availableCategories.map((category) => (
 | 
				
			||||||
 | 
					            <div key={category} className="category-card">
 | 
				
			||||||
 | 
					              <div className="category-header">
 | 
				
			||||||
 | 
					                <h3
 | 
				
			||||||
 | 
					                  className="category-title"
 | 
				
			||||||
 | 
					                  onClick={() => setSelectedCategory(category)}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  {category}
 | 
				
			||||||
 | 
					                </h3>
 | 
				
			||||||
 | 
					                <div className="category-stats">
 | 
				
			||||||
 | 
					                  <span className="feed-count">
 | 
				
			||||||
 | 
					                    {getFeedCount(category)} フィード
 | 
				
			||||||
 | 
					                  </span>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <div className="category-feeds-preview">
 | 
				
			||||||
 | 
					                {groupedFeeds[category]?.slice(0, 3).map((feed) => (
 | 
				
			||||||
 | 
					                  <div key={feed.id} className="feed-preview">
 | 
				
			||||||
 | 
					                    <Link to={`/feeds/${feed.id}`} className="feed-preview-link">
 | 
				
			||||||
 | 
					                      {feed.title || feed.url}
 | 
				
			||||||
 | 
					                    </Link>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                ))}
 | 
				
			||||||
 | 
					                {getFeedCount(category) > 3 && (
 | 
				
			||||||
 | 
					                  <div className="more-feeds">
 | 
				
			||||||
 | 
					                    他 {getFeedCount(category) - 3} フィード...
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <div className="category-actions">
 | 
				
			||||||
 | 
					                <button
 | 
				
			||||||
 | 
					                  className="btn btn-primary"
 | 
				
			||||||
 | 
					                  onClick={() => setSelectedCategory(category)}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  詳細を見る
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          ))}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      ) : (
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					          <div className="category-detail-header">
 | 
				
			||||||
 | 
					            <button
 | 
				
			||||||
 | 
					              className="btn btn-secondary"
 | 
				
			||||||
 | 
					              onClick={() => setSelectedCategory(null)}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              ← カテゴリ一覧に戻る
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					            <h3>カテゴリ: {selectedCategory}</h3>
 | 
				
			||||||
 | 
					            <p>{filteredFeeds.length} フィード</p>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <div className="feed-grid">
 | 
				
			||||||
 | 
					            {filteredFeeds.map((feed) => (
 | 
				
			||||||
 | 
					              <div key={feed.id} className="feed-card">
 | 
				
			||||||
 | 
					                <div className="feed-card-header">
 | 
				
			||||||
 | 
					                  <h4 className="feed-title">
 | 
				
			||||||
 | 
					                    <Link to={`/feeds/${feed.id}`} className="feed-link">
 | 
				
			||||||
 | 
					                      {feed.title || feed.url}
 | 
				
			||||||
 | 
					                    </Link>
 | 
				
			||||||
 | 
					                  </h4>
 | 
				
			||||||
 | 
					                  <div className="feed-url">
 | 
				
			||||||
 | 
					                    <a
 | 
				
			||||||
 | 
					                      href={feed.url}
 | 
				
			||||||
 | 
					                      target="_blank"
 | 
				
			||||||
 | 
					                      rel="noopener noreferrer"
 | 
				
			||||||
 | 
					                    >
 | 
				
			||||||
 | 
					                      {feed.url}
 | 
				
			||||||
 | 
					                    </a>
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                {feed.description && (
 | 
				
			||||||
 | 
					                  <div className="feed-description">{feed.description}</div>
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <div className="feed-meta">
 | 
				
			||||||
 | 
					                  <div>作成日: {formatDate(feed.createdAt)}</div>
 | 
				
			||||||
 | 
					                  {feed.lastUpdated && (
 | 
				
			||||||
 | 
					                    <div>最終更新: {formatDate(feed.lastUpdated)}</div>
 | 
				
			||||||
 | 
					                  )}
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <div className="feed-actions">
 | 
				
			||||||
 | 
					                  <Link to={`/feeds/${feed.id}`} className="btn btn-primary">
 | 
				
			||||||
 | 
					                    エピソード一覧を見る
 | 
				
			||||||
 | 
					                  </Link>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            ))}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <style>{`
 | 
				
			||||||
 | 
					        .category-grid {
 | 
				
			||||||
 | 
					          display: grid;
 | 
				
			||||||
 | 
					          grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
 | 
				
			||||||
 | 
					          gap: 20px;
 | 
				
			||||||
 | 
					          margin-bottom: 30px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-card {
 | 
				
			||||||
 | 
					          border: 1px solid #e9ecef;
 | 
				
			||||||
 | 
					          border-radius: 12px;
 | 
				
			||||||
 | 
					          padding: 20px;
 | 
				
			||||||
 | 
					          background: white;
 | 
				
			||||||
 | 
					          box-shadow: 0 2px 8px rgba(0,0,0,0.1);
 | 
				
			||||||
 | 
					          transition: transform 0.2s ease, box-shadow 0.2s ease;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-card:hover {
 | 
				
			||||||
 | 
					          transform: translateY(-2px);
 | 
				
			||||||
 | 
					          box-shadow: 0 4px 16px rgba(0,0,0,0.15);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-header {
 | 
				
			||||||
 | 
					          margin-bottom: 15px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-title {
 | 
				
			||||||
 | 
					          margin: 0 0 8px 0;
 | 
				
			||||||
 | 
					          font-size: 20px;
 | 
				
			||||||
 | 
					          color: #2c3e50;
 | 
				
			||||||
 | 
					          cursor: pointer;
 | 
				
			||||||
 | 
					          transition: color 0.2s ease;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-title:hover {
 | 
				
			||||||
 | 
					          color: #007bff;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-stats {
 | 
				
			||||||
 | 
					          display: flex;
 | 
				
			||||||
 | 
					          gap: 15px;
 | 
				
			||||||
 | 
					          font-size: 14px;
 | 
				
			||||||
 | 
					          color: #6c757d;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-count {
 | 
				
			||||||
 | 
					          background: #f8f9fa;
 | 
				
			||||||
 | 
					          padding: 4px 8px;
 | 
				
			||||||
 | 
					          border-radius: 4px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-feeds-preview {
 | 
				
			||||||
 | 
					          margin-bottom: 15px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-preview {
 | 
				
			||||||
 | 
					          margin-bottom: 6px;
 | 
				
			||||||
 | 
					          font-size: 14px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-preview-link {
 | 
				
			||||||
 | 
					          color: #007bff;
 | 
				
			||||||
 | 
					          text-decoration: none;
 | 
				
			||||||
 | 
					          display: block;
 | 
				
			||||||
 | 
					          white-space: nowrap;
 | 
				
			||||||
 | 
					          overflow: hidden;
 | 
				
			||||||
 | 
					          text-overflow: ellipsis;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-preview-link:hover {
 | 
				
			||||||
 | 
					          text-decoration: underline;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .more-feeds {
 | 
				
			||||||
 | 
					          font-size: 12px;
 | 
				
			||||||
 | 
					          color: #6c757d;
 | 
				
			||||||
 | 
					          font-style: italic;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-actions {
 | 
				
			||||||
 | 
					          text-align: right;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-detail-header {
 | 
				
			||||||
 | 
					          margin-bottom: 25px;
 | 
				
			||||||
 | 
					          padding-bottom: 15px;
 | 
				
			||||||
 | 
					          border-bottom: 1px solid #e9ecef;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-detail-header h3 {
 | 
				
			||||||
 | 
					          margin: 10px 0 5px 0;
 | 
				
			||||||
 | 
					          color: #2c3e50;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-detail-header p {
 | 
				
			||||||
 | 
					          margin: 0;
 | 
				
			||||||
 | 
					          color: #6c757d;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-grid {
 | 
				
			||||||
 | 
					          display: grid;
 | 
				
			||||||
 | 
					          grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
 | 
				
			||||||
 | 
					          gap: 20px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-card {
 | 
				
			||||||
 | 
					          border: 1px solid #e9ecef;
 | 
				
			||||||
 | 
					          border-radius: 8px;
 | 
				
			||||||
 | 
					          padding: 20px;
 | 
				
			||||||
 | 
					          background: white;
 | 
				
			||||||
 | 
					          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-card-header {
 | 
				
			||||||
 | 
					          margin-bottom: 15px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-title {
 | 
				
			||||||
 | 
					          margin: 0 0 8px 0;
 | 
				
			||||||
 | 
					          font-size: 16px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-link {
 | 
				
			||||||
 | 
					          text-decoration: none;
 | 
				
			||||||
 | 
					          color: #007bff;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-link:hover {
 | 
				
			||||||
 | 
					          text-decoration: underline;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-url {
 | 
				
			||||||
 | 
					          font-size: 12px;
 | 
				
			||||||
 | 
					          color: #666;
 | 
				
			||||||
 | 
					          word-break: break-all;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-url a {
 | 
				
			||||||
 | 
					          color: #666;
 | 
				
			||||||
 | 
					          text-decoration: none;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-url a:hover {
 | 
				
			||||||
 | 
					          color: #007bff;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-description {
 | 
				
			||||||
 | 
					          margin-bottom: 15px;
 | 
				
			||||||
 | 
					          color: #333;
 | 
				
			||||||
 | 
					          line-height: 1.5;
 | 
				
			||||||
 | 
					          font-size: 14px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-meta {
 | 
				
			||||||
 | 
					          margin-bottom: 15px;
 | 
				
			||||||
 | 
					          font-size: 12px;
 | 
				
			||||||
 | 
					          color: #666;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-meta div {
 | 
				
			||||||
 | 
					          margin-bottom: 4px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-actions {
 | 
				
			||||||
 | 
					          text-align: right;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      `}</style>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default CategoryList;
 | 
				
			||||||
@@ -27,10 +27,14 @@ interface EpisodeWithFeedInfo {
 | 
				
			|||||||
  feedId: string;
 | 
					  feedId: string;
 | 
				
			||||||
  feedTitle?: string;
 | 
					  feedTitle?: string;
 | 
				
			||||||
  feedUrl: string;
 | 
					  feedUrl: string;
 | 
				
			||||||
 | 
					  feedCategory?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function EpisodeList() {
 | 
					function EpisodeList() {
 | 
				
			||||||
  const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
 | 
					  const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
 | 
				
			||||||
 | 
					  const [filteredEpisodes, setFilteredEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
 | 
				
			||||||
 | 
					  const [categories, setCategories] = useState<string[]>([]);
 | 
				
			||||||
 | 
					  const [selectedCategory, setSelectedCategory] = useState<string>("");
 | 
				
			||||||
  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 [currentAudio, setCurrentAudio] = useState<string | null>(null);
 | 
					  const [currentAudio, setCurrentAudio] = useState<string | null>(null);
 | 
				
			||||||
@@ -38,8 +42,13 @@ function EpisodeList() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    fetchEpisodes();
 | 
					    fetchEpisodes();
 | 
				
			||||||
 | 
					    fetchCategories();
 | 
				
			||||||
  }, [useDatabase]);
 | 
					  }, [useDatabase]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    filterEpisodesByCategory();
 | 
				
			||||||
 | 
					  }, [episodes, selectedCategory]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const fetchEpisodes = async () => {
 | 
					  const fetchEpisodes = async () => {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      setLoading(true);
 | 
					      setLoading(true);
 | 
				
			||||||
@@ -106,6 +115,29 @@ function EpisodeList() {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const fetchCategories = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const response = await fetch("/api/categories");
 | 
				
			||||||
 | 
					      if (response.ok) {
 | 
				
			||||||
 | 
					        const data = await response.json();
 | 
				
			||||||
 | 
					        setCategories(data.categories || []);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      console.error("Error fetching categories:", err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const filterEpisodesByCategory = () => {
 | 
				
			||||||
 | 
					    if (!selectedCategory) {
 | 
				
			||||||
 | 
					      setFilteredEpisodes(episodes);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      const filtered = episodes.filter(episode => 
 | 
				
			||||||
 | 
					        episode.feedCategory === selectedCategory
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      setFilteredEpisodes(filtered);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const formatDate = (dateString: string) => {
 | 
					  const formatDate = (dateString: string) => {
 | 
				
			||||||
    return new Date(dateString).toLocaleString("ja-JP");
 | 
					    return new Date(dateString).toLocaleString("ja-JP");
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
@@ -165,6 +197,21 @@ function EpisodeList() {
 | 
				
			|||||||
    return <div className="error">{error}</div>;
 | 
					    return <div className="error">{error}</div>;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (filteredEpisodes.length === 0 && episodes.length > 0 && selectedCategory) {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <div className="empty-state">
 | 
				
			||||||
 | 
					        <p>カテゴリ「{selectedCategory}」のエピソードがありません</p>
 | 
				
			||||||
 | 
					        <button
 | 
				
			||||||
 | 
					          className="btn btn-secondary"
 | 
				
			||||||
 | 
					          onClick={() => setSelectedCategory("")}
 | 
				
			||||||
 | 
					          style={{ marginTop: "10px" }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          全てのエピソードを表示
 | 
				
			||||||
 | 
					        </button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (episodes.length === 0) {
 | 
					  if (episodes.length === 0) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className="empty-state">
 | 
					      <div className="empty-state">
 | 
				
			||||||
@@ -193,8 +240,27 @@ function EpisodeList() {
 | 
				
			|||||||
          alignItems: "center",
 | 
					          alignItems: "center",
 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <h2>エピソード一覧 ({episodes.length}件)</h2>
 | 
					        <h2>エピソード一覧 ({selectedCategory ? `${filteredEpisodes.length}/${episodes.length}` : episodes.length}件)</h2>
 | 
				
			||||||
        <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
 | 
					        <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
 | 
				
			||||||
 | 
					          {categories.length > 0 && (
 | 
				
			||||||
 | 
					            <select
 | 
				
			||||||
 | 
					              value={selectedCategory}
 | 
				
			||||||
 | 
					              onChange={(e) => setSelectedCategory(e.target.value)}
 | 
				
			||||||
 | 
					              style={{
 | 
				
			||||||
 | 
					                padding: "5px 10px",
 | 
				
			||||||
 | 
					                fontSize: "14px",
 | 
				
			||||||
 | 
					                border: "1px solid #ccc",
 | 
				
			||||||
 | 
					                borderRadius: "4px",
 | 
				
			||||||
 | 
					              }}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <option value="">全カテゴリ</option>
 | 
				
			||||||
 | 
					              {categories.map((category) => (
 | 
				
			||||||
 | 
					                <option key={category} value={category}>
 | 
				
			||||||
 | 
					                  {category}
 | 
				
			||||||
 | 
					                </option>
 | 
				
			||||||
 | 
					              ))}
 | 
				
			||||||
 | 
					            </select>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
          <span style={{ fontSize: "12px", color: "#666" }}>
 | 
					          <span style={{ fontSize: "12px", color: "#666" }}>
 | 
				
			||||||
            データソース: {useDatabase ? "データベース" : "XML"}
 | 
					            データソース: {useDatabase ? "データベース" : "XML"}
 | 
				
			||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
@@ -214,7 +280,7 @@ function EpisodeList() {
 | 
				
			|||||||
          </tr>
 | 
					          </tr>
 | 
				
			||||||
        </thead>
 | 
					        </thead>
 | 
				
			||||||
        <tbody>
 | 
					        <tbody>
 | 
				
			||||||
          {episodes.map((episode) => (
 | 
					          {filteredEpisodes.map((episode) => (
 | 
				
			||||||
            <tr key={episode.id}>
 | 
					            <tr key={episode.id}>
 | 
				
			||||||
              <td>
 | 
					              <td>
 | 
				
			||||||
                <div style={{ marginBottom: "8px" }}>
 | 
					                <div style={{ marginBottom: "8px" }}>
 | 
				
			||||||
@@ -242,6 +308,11 @@ function EpisodeList() {
 | 
				
			|||||||
                    >
 | 
					                    >
 | 
				
			||||||
                      {episode.feedTitle}
 | 
					                      {episode.feedTitle}
 | 
				
			||||||
                    </Link>
 | 
					                    </Link>
 | 
				
			||||||
 | 
					                    {episode.feedCategory && (
 | 
				
			||||||
 | 
					                      <span style={{ marginLeft: "8px", color: "#999", fontSize: "11px" }}>
 | 
				
			||||||
 | 
					                        ({episode.feedCategory})
 | 
				
			||||||
 | 
					                      </span>
 | 
				
			||||||
 | 
					                    )}
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
                )}
 | 
					                )}
 | 
				
			||||||
                {episode.articleTitle &&
 | 
					                {episode.articleTitle &&
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,6 +6,7 @@ interface Feed {
 | 
				
			|||||||
  url: string;
 | 
					  url: string;
 | 
				
			||||||
  title?: string;
 | 
					  title?: string;
 | 
				
			||||||
  description?: string;
 | 
					  description?: string;
 | 
				
			||||||
 | 
					  category?: string;
 | 
				
			||||||
  lastUpdated?: string;
 | 
					  lastUpdated?: string;
 | 
				
			||||||
  createdAt: string;
 | 
					  createdAt: string;
 | 
				
			||||||
  active: boolean;
 | 
					  active: boolean;
 | 
				
			||||||
@@ -13,13 +14,21 @@ interface Feed {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
function FeedList() {
 | 
					function FeedList() {
 | 
				
			||||||
  const [feeds, setFeeds] = useState<Feed[]>([]);
 | 
					  const [feeds, setFeeds] = useState<Feed[]>([]);
 | 
				
			||||||
 | 
					  const [filteredFeeds, setFilteredFeeds] = useState<Feed[]>([]);
 | 
				
			||||||
 | 
					  const [categories, setCategories] = useState<string[]>([]);
 | 
				
			||||||
 | 
					  const [selectedCategory, setSelectedCategory] = useState<string>("");
 | 
				
			||||||
  const [loading, setLoading] = useState(true);
 | 
					  const [loading, setLoading] = useState(true);
 | 
				
			||||||
  const [error, setError] = useState<string | null>(null);
 | 
					  const [error, setError] = useState<string | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    fetchFeeds();
 | 
					    fetchFeeds();
 | 
				
			||||||
 | 
					    fetchCategories();
 | 
				
			||||||
  }, []);
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    filterFeedsByCategory();
 | 
				
			||||||
 | 
					  }, [feeds, selectedCategory]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const fetchFeeds = async () => {
 | 
					  const fetchFeeds = async () => {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      setLoading(true);
 | 
					      setLoading(true);
 | 
				
			||||||
@@ -38,6 +47,29 @@ function FeedList() {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const fetchCategories = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const response = await fetch("/api/categories");
 | 
				
			||||||
 | 
					      if (response.ok) {
 | 
				
			||||||
 | 
					        const data = await response.json();
 | 
				
			||||||
 | 
					        setCategories(data.categories || []);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      console.error("Error fetching categories:", err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const filterFeedsByCategory = () => {
 | 
				
			||||||
 | 
					    if (!selectedCategory) {
 | 
				
			||||||
 | 
					      setFilteredFeeds(feeds);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      const filtered = feeds.filter(feed => 
 | 
				
			||||||
 | 
					        feed.category === selectedCategory
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      setFilteredFeeds(filtered);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const formatDate = (dateString: string) => {
 | 
					  const formatDate = (dateString: string) => {
 | 
				
			||||||
    return new Date(dateString).toLocaleString("ja-JP");
 | 
					    return new Date(dateString).toLocaleString("ja-JP");
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
@@ -50,6 +82,21 @@ function FeedList() {
 | 
				
			|||||||
    return <div className="error">{error}</div>;
 | 
					    return <div className="error">{error}</div>;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (filteredFeeds.length === 0 && feeds.length > 0 && selectedCategory) {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <div className="empty-state">
 | 
				
			||||||
 | 
					        <p>カテゴリ「{selectedCategory}」のフィードがありません</p>
 | 
				
			||||||
 | 
					        <button
 | 
				
			||||||
 | 
					          className="btn btn-secondary"
 | 
				
			||||||
 | 
					          onClick={() => setSelectedCategory("")}
 | 
				
			||||||
 | 
					          style={{ marginTop: "10px" }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          全てのフィードを表示
 | 
				
			||||||
 | 
					        </button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (feeds.length === 0) {
 | 
					  if (feeds.length === 0) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className="empty-state">
 | 
					      <div className="empty-state">
 | 
				
			||||||
@@ -78,14 +125,35 @@ function FeedList() {
 | 
				
			|||||||
          alignItems: "center",
 | 
					          alignItems: "center",
 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <h2>フィード一覧 ({feeds.length}件)</h2>
 | 
					        <h2>フィード一覧 ({selectedCategory ? `${filteredFeeds.length}/${feeds.length}` : feeds.length}件)</h2>
 | 
				
			||||||
        <button className="btn btn-secondary" onClick={fetchFeeds}>
 | 
					        <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
 | 
				
			||||||
          更新
 | 
					          {categories.length > 0 && (
 | 
				
			||||||
        </button>
 | 
					            <select
 | 
				
			||||||
 | 
					              value={selectedCategory}
 | 
				
			||||||
 | 
					              onChange={(e) => setSelectedCategory(e.target.value)}
 | 
				
			||||||
 | 
					              style={{
 | 
				
			||||||
 | 
					                padding: "5px 10px",
 | 
				
			||||||
 | 
					                fontSize: "14px",
 | 
				
			||||||
 | 
					                border: "1px solid #ccc",
 | 
				
			||||||
 | 
					                borderRadius: "4px",
 | 
				
			||||||
 | 
					              }}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <option value="">全カテゴリ</option>
 | 
				
			||||||
 | 
					              {categories.map((category) => (
 | 
				
			||||||
 | 
					                <option key={category} value={category}>
 | 
				
			||||||
 | 
					                  {category}
 | 
				
			||||||
 | 
					                </option>
 | 
				
			||||||
 | 
					              ))}
 | 
				
			||||||
 | 
					            </select>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					          <button className="btn btn-secondary" onClick={fetchFeeds}>
 | 
				
			||||||
 | 
					            更新
 | 
				
			||||||
 | 
					          </button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div className="feed-grid">
 | 
					      <div className="feed-grid">
 | 
				
			||||||
        {feeds.map((feed) => (
 | 
					        {filteredFeeds.map((feed) => (
 | 
				
			||||||
          <div key={feed.id} className="feed-card">
 | 
					          <div key={feed.id} className="feed-card">
 | 
				
			||||||
            <div className="feed-card-header">
 | 
					            <div className="feed-card-header">
 | 
				
			||||||
              <h3 className="feed-title">
 | 
					              <h3 className="feed-title">
 | 
				
			||||||
@@ -104,6 +172,12 @@ function FeedList() {
 | 
				
			|||||||
              <div className="feed-description">{feed.description}</div>
 | 
					              <div className="feed-description">{feed.description}</div>
 | 
				
			||||||
            )}
 | 
					            )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            {feed.category && (
 | 
				
			||||||
 | 
					              <div className="feed-category">
 | 
				
			||||||
 | 
					                <span className="category-badge">{feed.category}</span>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div className="feed-meta">
 | 
					            <div className="feed-meta">
 | 
				
			||||||
              <div>作成日: {formatDate(feed.createdAt)}</div>
 | 
					              <div>作成日: {formatDate(feed.createdAt)}</div>
 | 
				
			||||||
              {feed.lastUpdated && (
 | 
					              {feed.lastUpdated && (
 | 
				
			||||||
@@ -187,6 +261,20 @@ function FeedList() {
 | 
				
			|||||||
        .feed-actions {
 | 
					        .feed-actions {
 | 
				
			||||||
          text-align: right;
 | 
					          text-align: right;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .feed-category {
 | 
				
			||||||
 | 
					          margin-bottom: 15px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        .category-badge {
 | 
				
			||||||
 | 
					          display: inline-block;
 | 
				
			||||||
 | 
					          background: #007bff;
 | 
				
			||||||
 | 
					          color: white;
 | 
				
			||||||
 | 
					          padding: 3px 8px;
 | 
				
			||||||
 | 
					          border-radius: 12px;
 | 
				
			||||||
 | 
					          font-size: 11px;
 | 
				
			||||||
 | 
					          font-weight: 500;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      `}</style>
 | 
					      `}</style>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS feeds (
 | 
				
			|||||||
  url TEXT NOT NULL UNIQUE,
 | 
					  url TEXT NOT NULL UNIQUE,
 | 
				
			||||||
  title TEXT,
 | 
					  title TEXT,
 | 
				
			||||||
  description TEXT,
 | 
					  description TEXT,
 | 
				
			||||||
 | 
					  category TEXT,
 | 
				
			||||||
  last_updated TEXT,
 | 
					  last_updated TEXT,
 | 
				
			||||||
  created_at TEXT NOT NULL,
 | 
					  created_at TEXT NOT NULL,
 | 
				
			||||||
  active BOOLEAN DEFAULT 1
 | 
					  active BOOLEAN DEFAULT 1
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,6 +18,7 @@ class BatchScheduler {
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private currentAbortController?: AbortController;
 | 
					  private currentAbortController?: AbortController;
 | 
				
			||||||
 | 
					  private migrationCompleted = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private readonly SIX_HOURS_MS = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
 | 
					  private readonly SIX_HOURS_MS = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -84,6 +85,28 @@ class BatchScheduler {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      console.log("🔄 Running scheduled batch process...");
 | 
					      console.log("🔄 Running scheduled batch process...");
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Run migration for feeds without categories (only once)
 | 
				
			||||||
 | 
					      if (!this.migrationCompleted) {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					          const { migrateFeedsWithCategories, getFeedCategoryMigrationStatus } = await import("./database.js");
 | 
				
			||||||
 | 
					          const migrationStatus = await getFeedCategoryMigrationStatus();
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          if (!migrationStatus.migrationComplete) {
 | 
				
			||||||
 | 
					            console.log("🔄 Running feed category migration...");
 | 
				
			||||||
 | 
					            await migrateFeedsWithCategories();
 | 
				
			||||||
 | 
					            this.migrationCompleted = true;
 | 
				
			||||||
 | 
					          } else {
 | 
				
			||||||
 | 
					            console.log("✅ Feed category migration already complete");
 | 
				
			||||||
 | 
					            this.migrationCompleted = true;
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        } catch (migrationError) {
 | 
				
			||||||
 | 
					          console.error("❌ Error during feed category migration:", migrationError);
 | 
				
			||||||
 | 
					          // Don't fail the entire batch process due to migration error
 | 
				
			||||||
 | 
					          this.migrationCompleted = true; // Mark as completed to avoid retrying every batch
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
      await batchProcess(this.currentAbortController.signal);
 | 
					      await batchProcess(this.currentAbortController.signal);
 | 
				
			||||||
      console.log("✅ Scheduled batch process completed");
 | 
					      console.log("✅ Scheduled batch process completed");
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -131,6 +131,7 @@ function initializeDatabase(): Database {
 | 
				
			|||||||
    url TEXT NOT NULL UNIQUE,
 | 
					    url TEXT NOT NULL UNIQUE,
 | 
				
			||||||
    title TEXT,
 | 
					    title TEXT,
 | 
				
			||||||
    description TEXT,
 | 
					    description TEXT,
 | 
				
			||||||
 | 
					    category TEXT,
 | 
				
			||||||
    last_updated TEXT,
 | 
					    last_updated TEXT,
 | 
				
			||||||
    created_at TEXT NOT NULL,
 | 
					    created_at TEXT NOT NULL,
 | 
				
			||||||
    active BOOLEAN DEFAULT 1
 | 
					    active BOOLEAN DEFAULT 1
 | 
				
			||||||
@@ -267,6 +268,7 @@ export interface EpisodeWithFeedInfo {
 | 
				
			|||||||
  feedId: string;
 | 
					  feedId: string;
 | 
				
			||||||
  feedTitle?: string;
 | 
					  feedTitle?: string;
 | 
				
			||||||
  feedUrl: string;
 | 
					  feedUrl: string;
 | 
				
			||||||
 | 
					  feedCategory?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Feed management functions
 | 
					// Feed management functions
 | 
				
			||||||
@@ -280,11 +282,12 @@ export async function saveFeed(
 | 
				
			|||||||
    if (existingFeed) {
 | 
					    if (existingFeed) {
 | 
				
			||||||
      // Update existing feed
 | 
					      // Update existing feed
 | 
				
			||||||
      const updateStmt = db.prepare(
 | 
					      const updateStmt = db.prepare(
 | 
				
			||||||
        "UPDATE feeds SET title = ?, description = ?, last_updated = ?, active = ? WHERE url = ?",
 | 
					        "UPDATE feeds SET title = ?, description = ?, category = ?, last_updated = ?, active = ? WHERE url = ?",
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
      updateStmt.run(
 | 
					      updateStmt.run(
 | 
				
			||||||
        feed.title || null,
 | 
					        feed.title || null,
 | 
				
			||||||
        feed.description || null,
 | 
					        feed.description || null,
 | 
				
			||||||
 | 
					        feed.category || null,
 | 
				
			||||||
        feed.lastUpdated || null,
 | 
					        feed.lastUpdated || null,
 | 
				
			||||||
        feed.active !== undefined ? (feed.active ? 1 : 0) : 1,
 | 
					        feed.active !== undefined ? (feed.active ? 1 : 0) : 1,
 | 
				
			||||||
        feed.url,
 | 
					        feed.url,
 | 
				
			||||||
@@ -296,13 +299,14 @@ export async function saveFeed(
 | 
				
			|||||||
      const createdAt = new Date().toISOString();
 | 
					      const createdAt = new Date().toISOString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const insertStmt = db.prepare(
 | 
					      const insertStmt = db.prepare(
 | 
				
			||||||
        "INSERT INTO feeds (id, url, title, description, last_updated, created_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)",
 | 
					        "INSERT INTO feeds (id, url, title, description, category, last_updated, created_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
      insertStmt.run(
 | 
					      insertStmt.run(
 | 
				
			||||||
        id,
 | 
					        id,
 | 
				
			||||||
        feed.url,
 | 
					        feed.url,
 | 
				
			||||||
        feed.title || null,
 | 
					        feed.title || null,
 | 
				
			||||||
        feed.description || null,
 | 
					        feed.description || null,
 | 
				
			||||||
 | 
					        feed.category || null,
 | 
				
			||||||
        feed.lastUpdated || null,
 | 
					        feed.lastUpdated || null,
 | 
				
			||||||
        createdAt,
 | 
					        createdAt,
 | 
				
			||||||
        feed.active !== undefined ? (feed.active ? 1 : 0) : 1,
 | 
					        feed.active !== undefined ? (feed.active ? 1 : 0) : 1,
 | 
				
			||||||
@@ -407,7 +411,8 @@ export async function fetchEpisodesWithFeedInfo(): Promise<
 | 
				
			|||||||
        a.pub_date as articlePubDate,
 | 
					        a.pub_date as articlePubDate,
 | 
				
			||||||
        f.id as feedId,
 | 
					        f.id as feedId,
 | 
				
			||||||
        f.title as feedTitle,
 | 
					        f.title as feedTitle,
 | 
				
			||||||
        f.url as feedUrl
 | 
					        f.url as feedUrl,
 | 
				
			||||||
 | 
					        f.category as feedCategory
 | 
				
			||||||
      FROM episodes e
 | 
					      FROM episodes e
 | 
				
			||||||
      JOIN articles a ON e.article_id = a.id
 | 
					      JOIN articles a ON e.article_id = a.id
 | 
				
			||||||
      JOIN feeds f ON a.feed_id = f.id
 | 
					      JOIN feeds f ON a.feed_id = f.id
 | 
				
			||||||
@@ -432,6 +437,7 @@ export async function fetchEpisodesWithFeedInfo(): Promise<
 | 
				
			|||||||
      feedId: row.feedId,
 | 
					      feedId: row.feedId,
 | 
				
			||||||
      feedTitle: row.feedTitle,
 | 
					      feedTitle: row.feedTitle,
 | 
				
			||||||
      feedUrl: row.feedUrl,
 | 
					      feedUrl: row.feedUrl,
 | 
				
			||||||
 | 
					      feedCategory: row.feedCategory,
 | 
				
			||||||
    }));
 | 
					    }));
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error("Error fetching episodes with feed info:", error);
 | 
					    console.error("Error fetching episodes with feed info:", error);
 | 
				
			||||||
@@ -459,7 +465,8 @@ export async function fetchEpisodesByFeedId(
 | 
				
			|||||||
        a.pub_date as articlePubDate,
 | 
					        a.pub_date as articlePubDate,
 | 
				
			||||||
        f.id as feedId,
 | 
					        f.id as feedId,
 | 
				
			||||||
        f.title as feedTitle,
 | 
					        f.title as feedTitle,
 | 
				
			||||||
        f.url as feedUrl
 | 
					        f.url as feedUrl,
 | 
				
			||||||
 | 
					        f.category as feedCategory
 | 
				
			||||||
      FROM episodes e
 | 
					      FROM episodes e
 | 
				
			||||||
      JOIN articles a ON e.article_id = a.id
 | 
					      JOIN articles a ON e.article_id = a.id
 | 
				
			||||||
      JOIN feeds f ON a.feed_id = f.id
 | 
					      JOIN feeds f ON a.feed_id = f.id
 | 
				
			||||||
@@ -484,6 +491,7 @@ export async function fetchEpisodesByFeedId(
 | 
				
			|||||||
      feedId: row.feedId,
 | 
					      feedId: row.feedId,
 | 
				
			||||||
      feedTitle: row.feedTitle,
 | 
					      feedTitle: row.feedTitle,
 | 
				
			||||||
      feedUrl: row.feedUrl,
 | 
					      feedUrl: row.feedUrl,
 | 
				
			||||||
 | 
					      feedCategory: row.feedCategory,
 | 
				
			||||||
    }));
 | 
					    }));
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error("Error fetching episodes by feed ID:", error);
 | 
					    console.error("Error fetching episodes by feed ID:", error);
 | 
				
			||||||
@@ -511,7 +519,8 @@ export async function fetchEpisodeWithSourceInfo(
 | 
				
			|||||||
        a.pub_date as articlePubDate,
 | 
					        a.pub_date as articlePubDate,
 | 
				
			||||||
        f.id as feedId,
 | 
					        f.id as feedId,
 | 
				
			||||||
        f.title as feedTitle,
 | 
					        f.title as feedTitle,
 | 
				
			||||||
        f.url as feedUrl
 | 
					        f.url as feedUrl,
 | 
				
			||||||
 | 
					        f.category as feedCategory
 | 
				
			||||||
      FROM episodes e
 | 
					      FROM episodes e
 | 
				
			||||||
      JOIN articles a ON e.article_id = a.id
 | 
					      JOIN articles a ON e.article_id = a.id
 | 
				
			||||||
      JOIN feeds f ON a.feed_id = f.id
 | 
					      JOIN feeds f ON a.feed_id = f.id
 | 
				
			||||||
@@ -536,6 +545,7 @@ export async function fetchEpisodeWithSourceInfo(
 | 
				
			|||||||
      feedId: row.feedId,
 | 
					      feedId: row.feedId,
 | 
				
			||||||
      feedTitle: row.feedTitle,
 | 
					      feedTitle: row.feedTitle,
 | 
				
			||||||
      feedUrl: row.feedUrl,
 | 
					      feedUrl: row.feedUrl,
 | 
				
			||||||
 | 
					      feedCategory: row.feedCategory,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error("Error fetching episode with source info:", error);
 | 
					    console.error("Error fetching episode with source info:", error);
 | 
				
			||||||
@@ -1104,6 +1114,100 @@ export async function updateFeedRequestStatus(
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Migration function to classify existing feeds without categories
 | 
				
			||||||
 | 
					export async function migrateFeedsWithCategories(): Promise<void> {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    console.log("🔄 Starting feed category migration...");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Get all feeds without categories
 | 
				
			||||||
 | 
					    const stmt = db.prepare("SELECT * FROM feeds WHERE category IS NULL OR category = ''");
 | 
				
			||||||
 | 
					    const feedsWithoutCategories = stmt.all() as any[];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (feedsWithoutCategories.length === 0) {
 | 
				
			||||||
 | 
					      console.log("✅ All feeds already have categories assigned");
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    console.log(`📋 Found ${feedsWithoutCategories.length} feeds without categories`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Import LLM service
 | 
				
			||||||
 | 
					    const { openAI_ClassifyFeed } = await import("./llm.js");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let processedCount = 0;
 | 
				
			||||||
 | 
					    let errorCount = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const feed of feedsWithoutCategories) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        // Use title for classification, fallback to URL if no title
 | 
				
			||||||
 | 
					        const titleForClassification = feed.title || feed.url;
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        console.log(`🔍 Classifying feed: ${titleForClassification}`);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Classify the feed
 | 
				
			||||||
 | 
					        const category = await openAI_ClassifyFeed(titleForClassification);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Update the feed with the category
 | 
				
			||||||
 | 
					        const updateStmt = db.prepare("UPDATE feeds SET category = ? WHERE id = ?");
 | 
				
			||||||
 | 
					        updateStmt.run(category, feed.id);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        console.log(`✅ Assigned category "${category}" to feed: ${titleForClassification}`);
 | 
				
			||||||
 | 
					        processedCount++;
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Add a small delay to avoid rate limiting
 | 
				
			||||||
 | 
					        await new Promise(resolve => setTimeout(resolve, 1000));
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.error(`❌ Failed to classify feed ${feed.title || feed.url}:`, error);
 | 
				
			||||||
 | 
					        errorCount++;
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Set a default category for failed classifications
 | 
				
			||||||
 | 
					        const defaultCategory = "その他";
 | 
				
			||||||
 | 
					        const updateStmt = db.prepare("UPDATE feeds SET category = ? WHERE id = ?");
 | 
				
			||||||
 | 
					        updateStmt.run(defaultCategory, feed.id);
 | 
				
			||||||
 | 
					        console.log(`⚠️  Assigned default category "${defaultCategory}" to feed: ${feed.title || feed.url}`);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    console.log(`✅ Feed category migration completed`);
 | 
				
			||||||
 | 
					    console.log(`📊 Processed: ${processedCount}, Errors: ${errorCount}, Total: ${feedsWithoutCategories.length}`);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("❌ Error during feed category migration:", error);
 | 
				
			||||||
 | 
					    throw error;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Function to get migration status
 | 
				
			||||||
 | 
					export async function getFeedCategoryMigrationStatus(): Promise<{
 | 
				
			||||||
 | 
					  totalFeeds: number;
 | 
				
			||||||
 | 
					  feedsWithCategories: number;
 | 
				
			||||||
 | 
					  feedsWithoutCategories: number;
 | 
				
			||||||
 | 
					  migrationComplete: boolean;
 | 
				
			||||||
 | 
					}> {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const totalStmt = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1");
 | 
				
			||||||
 | 
					    const totalResult = totalStmt.get() as any;
 | 
				
			||||||
 | 
					    const totalFeeds = totalResult.count;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const withCategoriesStmt = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1 AND category IS NOT NULL AND category != ''");
 | 
				
			||||||
 | 
					    const withCategoriesResult = withCategoriesStmt.get() as any;
 | 
				
			||||||
 | 
					    const feedsWithCategories = withCategoriesResult.count;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const feedsWithoutCategories = totalFeeds - feedsWithCategories;
 | 
				
			||||||
 | 
					    const migrationComplete = feedsWithoutCategories === 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      totalFeeds,
 | 
				
			||||||
 | 
					      feedsWithCategories,
 | 
				
			||||||
 | 
					      feedsWithoutCategories,
 | 
				
			||||||
 | 
					      migrationComplete,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error getting migration status:", error);
 | 
				
			||||||
 | 
					    throw error;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function closeDatabase(): void {
 | 
					export function closeDatabase(): void {
 | 
				
			||||||
  db.close();
 | 
					  db.close();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user