Add admin panel
This commit is contained in:
		
							
								
								
									
										20
									
								
								CLAUDE.md
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								CLAUDE.md
									
									
									
									
									
								
							@@ -6,8 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
- **Install dependencies**: `bun install`
 | 
					- **Install dependencies**: `bun install`
 | 
				
			||||||
- **Build frontend**: `bun run build:frontend`
 | 
					- **Build frontend**: `bun run build:frontend`
 | 
				
			||||||
 | 
					- **Build admin panel**: `bun run build:admin`
 | 
				
			||||||
- **Start server**: `bun run start` or `bun run server.ts`
 | 
					- **Start server**: `bun run start` or `bun run server.ts`
 | 
				
			||||||
 | 
					- **Start admin panel**: `bun run admin` or `bun run admin-server.ts`
 | 
				
			||||||
- **Frontend development**: `bun run dev:frontend`
 | 
					- **Frontend development**: `bun run dev:frontend`
 | 
				
			||||||
 | 
					- **Admin panel development**: `bun run dev:admin`
 | 
				
			||||||
- **Manual batch process**: `bun run scripts/fetch_and_generate.ts`
 | 
					- **Manual batch process**: `bun run scripts/fetch_and_generate.ts`
 | 
				
			||||||
- **Type checking**: `bunx tsc --noEmit`
 | 
					- **Type checking**: `bunx tsc --noEmit`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -17,11 +20,12 @@ This is a RSS-to-podcast automation system built with Bun runtime, Hono web fram
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
### Core Components
 | 
					### Core Components
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- **server.ts**: Main Hono web server serving both API endpoints and static frontend files
 | 
					- **server.ts**: Main Hono web server serving UI and static content (port 3000)
 | 
				
			||||||
 | 
					- **admin-server.ts**: Admin panel server for management functions (port 3001 by default)
 | 
				
			||||||
- **scripts/fetch_and_generate.ts**: Batch processing script that fetches RSS feeds, generates summaries, and creates audio files
 | 
					- **scripts/fetch_and_generate.ts**: Batch processing script that fetches RSS feeds, generates summaries, and creates audio files
 | 
				
			||||||
- **services/**: Core business logic modules:
 | 
					- **services/**: Core business logic modules:
 | 
				
			||||||
  - `config.ts`: Centralized configuration management with validation
 | 
					  - `config.ts`: Centralized configuration management with validation
 | 
				
			||||||
  - `database.ts`: SQLite operations for episodes and feed tracking
 | 
					  - `database.ts`: SQLite operations for episodes and feed tracking (includes feed deletion)
 | 
				
			||||||
  - `llm.ts`: OpenAI integration for content generation and feed classification
 | 
					  - `llm.ts`: OpenAI integration for content generation and feed classification
 | 
				
			||||||
  - `tts.ts`: Text-to-speech via VOICEVOX API
 | 
					  - `tts.ts`: Text-to-speech via VOICEVOX API
 | 
				
			||||||
  - `podcast.ts`: RSS feed generation
 | 
					  - `podcast.ts`: RSS feed generation
 | 
				
			||||||
@@ -48,16 +52,26 @@ The application uses `services/config.ts` for centralized configuration manageme
 | 
				
			|||||||
- `OPENAI_API_KEY`: OpenAI API key (required)
 | 
					- `OPENAI_API_KEY`: OpenAI API key (required)
 | 
				
			||||||
- `VOICEVOX_HOST`: VOICEVOX server URL (default: http://localhost:50021)
 | 
					- `VOICEVOX_HOST`: VOICEVOX server URL (default: http://localhost:50021)
 | 
				
			||||||
- `VOICEVOX_STYLE_ID`: Voice style ID (default: 0)
 | 
					- `VOICEVOX_STYLE_ID`: Voice style ID (default: 0)
 | 
				
			||||||
 | 
					- `ADMIN_PORT`: Admin panel port (default: 3001)
 | 
				
			||||||
 | 
					- `ADMIN_USERNAME`: Admin panel username (optional, for basic auth)
 | 
				
			||||||
 | 
					- `ADMIN_PASSWORD`: Admin panel password (optional, for basic auth)
 | 
				
			||||||
- Podcast metadata and other optional settings
 | 
					- Podcast metadata and other optional settings
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Configuration is validated on startup. See README.md for complete list.
 | 
					Configuration is validated on startup. See README.md for complete list.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Deployment
 | 
					### Deployment
 | 
				
			||||||
 | 
					
 | 
				
			||||||
The application runs as a single server process on port 3000, automatically executing batch processing on startup and daily at midnight.
 | 
					The application runs as two separate servers:
 | 
				
			||||||
 | 
					- **Main server (port 3000)**: Serves the web UI, podcast.xml, and static files
 | 
				
			||||||
 | 
					- **Admin server (port 3001)**: Provides management interface with feed CRUD operations and environment variable management
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Both servers execute batch processing on startup and at regular intervals.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Recent Improvements
 | 
					### Recent Improvements
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Admin Panel**: Separate admin server with feed management, deletion, and environment variable configuration
 | 
				
			||||||
 | 
					- **Feed Management**: Added feed deletion functionality and active/inactive toggling
 | 
				
			||||||
 | 
					- **API Separation**: Moved all management APIs to admin server, keeping main server focused on content delivery
 | 
				
			||||||
- **ES Module Compatibility**: Fixed all ES module issues and removed deprecated `__dirname` usage
 | 
					- **ES Module Compatibility**: Fixed all ES module issues and removed deprecated `__dirname` usage
 | 
				
			||||||
- **Centralized Configuration**: Added `services/config.ts` for type-safe configuration management
 | 
					- **Centralized Configuration**: Added `services/config.ts` for type-safe configuration management
 | 
				
			||||||
- **Enhanced Error Handling**: Improved error handling and validation throughout the codebase
 | 
					- **Enhanced Error Handling**: Improved error handling and validation throughout the codebase
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										12
									
								
								admin-panel/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								admin-panel/index.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					<!doctype html>
 | 
				
			||||||
 | 
					<html lang="ja">
 | 
				
			||||||
 | 
					  <head>
 | 
				
			||||||
 | 
					    <meta charset="UTF-8" />
 | 
				
			||||||
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
				
			||||||
 | 
					    <title>Admin Panel - RSS Podcast Manager</title>
 | 
				
			||||||
 | 
					  </head>
 | 
				
			||||||
 | 
					  <body>
 | 
				
			||||||
 | 
					    <div id="root"></div>
 | 
				
			||||||
 | 
					    <script type="module" src="/src/main.tsx"></script>
 | 
				
			||||||
 | 
					  </body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
							
								
								
									
										21
									
								
								admin-panel/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								admin-panel/package.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "name": "admin-panel",
 | 
				
			||||||
 | 
					  "version": "1.0.0",
 | 
				
			||||||
 | 
					  "type": "module",
 | 
				
			||||||
 | 
					  "scripts": {
 | 
				
			||||||
 | 
					    "dev": "vite",
 | 
				
			||||||
 | 
					    "build": "tsc && vite build",
 | 
				
			||||||
 | 
					    "preview": "vite preview"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "dependencies": {
 | 
				
			||||||
 | 
					    "react": "^18.2.0",
 | 
				
			||||||
 | 
					    "react-dom": "^18.2.0"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "devDependencies": {
 | 
				
			||||||
 | 
					    "@types/react": "^18.2.0",
 | 
				
			||||||
 | 
					    "@types/react-dom": "^18.2.0",
 | 
				
			||||||
 | 
					    "@vitejs/plugin-react": "^4.0.3",
 | 
				
			||||||
 | 
					    "typescript": "^5.0.2",
 | 
				
			||||||
 | 
					    "vite": "^4.4.5"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										332
									
								
								admin-panel/src/App.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										332
									
								
								admin-panel/src/App.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,332 @@
 | 
				
			|||||||
 | 
					import React, { useState, useEffect } from 'react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Feed {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  url: string;
 | 
				
			||||||
 | 
					  title?: string;
 | 
				
			||||||
 | 
					  description?: string;
 | 
				
			||||||
 | 
					  lastUpdated?: string;
 | 
				
			||||||
 | 
					  createdAt: string;
 | 
				
			||||||
 | 
					  active: boolean;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Stats {
 | 
				
			||||||
 | 
					  totalFeeds: number;
 | 
				
			||||||
 | 
					  activeFeeds: number;
 | 
				
			||||||
 | 
					  inactiveFeeds: number;
 | 
				
			||||||
 | 
					  totalEpisodes: number;
 | 
				
			||||||
 | 
					  lastUpdated: string;
 | 
				
			||||||
 | 
					  adminPort: number;
 | 
				
			||||||
 | 
					  authEnabled: boolean;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface EnvVars {
 | 
				
			||||||
 | 
					  [key: string]: string | undefined;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function App() {
 | 
				
			||||||
 | 
					  const [feeds, setFeeds] = useState<Feed[]>([]);
 | 
				
			||||||
 | 
					  const [stats, setStats] = useState<Stats | null>(null);
 | 
				
			||||||
 | 
					  const [envVars, setEnvVars] = useState<EnvVars>({});
 | 
				
			||||||
 | 
					  const [loading, setLoading] = useState(true);
 | 
				
			||||||
 | 
					  const [error, setError] = useState<string | null>(null);
 | 
				
			||||||
 | 
					  const [success, setSuccess] = useState<string | null>(null);
 | 
				
			||||||
 | 
					  const [newFeedUrl, setNewFeedUrl] = useState('');
 | 
				
			||||||
 | 
					  const [activeTab, setActiveTab] = useState<'dashboard' | 'feeds' | 'env'>('dashboard');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    loadData();
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const loadData = async () => {
 | 
				
			||||||
 | 
					    setLoading(true);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const [feedsRes, statsRes, envRes] = await Promise.all([
 | 
				
			||||||
 | 
					        fetch('/api/admin/feeds'),
 | 
				
			||||||
 | 
					        fetch('/api/admin/stats'),
 | 
				
			||||||
 | 
					        fetch('/api/admin/env')
 | 
				
			||||||
 | 
					      ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!feedsRes.ok || !statsRes.ok || !envRes.ok) {
 | 
				
			||||||
 | 
					        throw new Error('Failed to load data');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const [feedsData, statsData, envData] = await Promise.all([
 | 
				
			||||||
 | 
					        feedsRes.json(),
 | 
				
			||||||
 | 
					        statsRes.json(),
 | 
				
			||||||
 | 
					        envRes.json()
 | 
				
			||||||
 | 
					      ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      setFeeds(feedsData);
 | 
				
			||||||
 | 
					      setStats(statsData);
 | 
				
			||||||
 | 
					      setEnvVars(envData);
 | 
				
			||||||
 | 
					      setError(null);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      setError('データの読み込みに失敗しました');
 | 
				
			||||||
 | 
					      console.error('Error loading data:', err);
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setLoading(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const addFeed = async (e: React.FormEvent) => {
 | 
				
			||||||
 | 
					    e.preventDefault();
 | 
				
			||||||
 | 
					    if (!newFeedUrl.trim()) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const res = await fetch('/api/admin/feeds', {
 | 
				
			||||||
 | 
					        method: 'POST',
 | 
				
			||||||
 | 
					        headers: { 'Content-Type': 'application/json' },
 | 
				
			||||||
 | 
					        body: JSON.stringify({ feedUrl: newFeedUrl })
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const data = await res.json();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (res.ok) {
 | 
				
			||||||
 | 
					        setSuccess(data.message);
 | 
				
			||||||
 | 
					        setNewFeedUrl('');
 | 
				
			||||||
 | 
					        loadData();
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        setError(data.error || 'フィード追加に失敗しました');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      setError('フィード追加に失敗しました');
 | 
				
			||||||
 | 
					      console.error('Error adding feed:', err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const deleteFeed = async (feedId: string) => {
 | 
				
			||||||
 | 
					    if (!confirm('本当にこのフィードを削除しますか?関連するすべての記事とエピソードも削除されます。')) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const res = await fetch(`/api/admin/feeds/${feedId}`, {
 | 
				
			||||||
 | 
					        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 feed:', err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const toggleFeed = async (feedId: string, active: boolean) => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const res = await fetch(`/api/admin/feeds/${feedId}/toggle`, {
 | 
				
			||||||
 | 
					        method: 'PATCH',
 | 
				
			||||||
 | 
					        headers: { 'Content-Type': 'application/json' },
 | 
				
			||||||
 | 
					        body: JSON.stringify({ active })
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const data = await res.json();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (res.ok) {
 | 
				
			||||||
 | 
					        setSuccess(data.message);
 | 
				
			||||||
 | 
					        loadData();
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        setError(data.error || 'フィードステータス変更に失敗しました');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      setError('フィードステータス変更に失敗しました');
 | 
				
			||||||
 | 
					      console.error('Error toggling feed:', err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const triggerBatch = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const res = await fetch('/api/admin/batch/trigger', {
 | 
				
			||||||
 | 
					        method: 'POST'
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const data = await res.json();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (res.ok) {
 | 
				
			||||||
 | 
					        setSuccess(data.message);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        setError(data.error || 'バッチ処理開始に失敗しました');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      setError('バッチ処理開始に失敗しました');
 | 
				
			||||||
 | 
					      console.error('Error triggering batch:', err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (loading) {
 | 
				
			||||||
 | 
					    return <div className="container"><div className="loading">読み込み中...</div></div>;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="container">
 | 
				
			||||||
 | 
					      <div className="header">
 | 
				
			||||||
 | 
					        <h1>管理者パネル</h1>
 | 
				
			||||||
 | 
					        <p className="subtitle">RSS Podcast Manager - 管理者用インターフェース</p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {error && <div className="error">{error}</div>}
 | 
				
			||||||
 | 
					      {success && <div className="success">{success}</div>}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div className="card">
 | 
				
			||||||
 | 
					        <div className="card-header">
 | 
				
			||||||
 | 
					          <nav style={{ display: 'flex', gap: '16px' }}>
 | 
				
			||||||
 | 
					            <button 
 | 
				
			||||||
 | 
					              className={`btn ${activeTab === 'dashboard' ? 'btn-primary' : 'btn-secondary'}`}
 | 
				
			||||||
 | 
					              onClick={() => setActiveTab('dashboard')}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              ダッシュボード
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					            <button 
 | 
				
			||||||
 | 
					              className={`btn ${activeTab === 'feeds' ? 'btn-primary' : 'btn-secondary'}`}
 | 
				
			||||||
 | 
					              onClick={() => setActiveTab('feeds')}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              フィード管理
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					            <button 
 | 
				
			||||||
 | 
					              className={`btn ${activeTab === 'env' ? 'btn-primary' : 'btn-secondary'}`}
 | 
				
			||||||
 | 
					              onClick={() => setActiveTab('env')}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              環境変数
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          </nav>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div className="card-content">
 | 
				
			||||||
 | 
					          {activeTab === 'dashboard' && (
 | 
				
			||||||
 | 
					            <>
 | 
				
			||||||
 | 
					              <div className="stats-grid">
 | 
				
			||||||
 | 
					                <div className="stat-card">
 | 
				
			||||||
 | 
					                  <div className="value">{stats?.totalFeeds || 0}</div>
 | 
				
			||||||
 | 
					                  <div className="label">総フィード数</div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div className="stat-card">
 | 
				
			||||||
 | 
					                  <div className="value">{stats?.activeFeeds || 0}</div>
 | 
				
			||||||
 | 
					                  <div className="label">アクティブフィード</div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div className="stat-card">
 | 
				
			||||||
 | 
					                  <div className="value">{stats?.inactiveFeeds || 0}</div>
 | 
				
			||||||
 | 
					                  <div className="label">非アクティブフィード</div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div className="stat-card">
 | 
				
			||||||
 | 
					                  <div className="value">{stats?.totalEpisodes || 0}</div>
 | 
				
			||||||
 | 
					                  <div className="label">総エピソード数</div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <div style={{ marginBottom: '20px' }}>
 | 
				
			||||||
 | 
					                <button className="btn btn-success" onClick={triggerBatch}>
 | 
				
			||||||
 | 
					                  バッチ処理を手動実行
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					                <button className="btn btn-primary" onClick={loadData} style={{ marginLeft: '8px' }}>
 | 
				
			||||||
 | 
					                  データを再読み込み
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <div style={{ fontSize: '14px', color: '#7f8c8d' }}>
 | 
				
			||||||
 | 
					                <p>管理者パネルポート: {stats?.adminPort}</p>
 | 
				
			||||||
 | 
					                <p>認証: {stats?.authEnabled ? '有効' : '無効'}</p>
 | 
				
			||||||
 | 
					                <p>最終更新: {stats?.lastUpdated ? new Date(stats.lastUpdated).toLocaleString('ja-JP') : '不明'}</p>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {activeTab === 'feeds' && (
 | 
				
			||||||
 | 
					            <>
 | 
				
			||||||
 | 
					              <form onSubmit={addFeed} className="add-feed-form">
 | 
				
			||||||
 | 
					                <div className="form-group">
 | 
				
			||||||
 | 
					                  <label htmlFor="feedUrl">新しいフィードURL</label>
 | 
				
			||||||
 | 
					                  <input
 | 
				
			||||||
 | 
					                    id="feedUrl"
 | 
				
			||||||
 | 
					                    type="url"
 | 
				
			||||||
 | 
					                    className="input"
 | 
				
			||||||
 | 
					                    value={newFeedUrl}
 | 
				
			||||||
 | 
					                    onChange={(e) => setNewFeedUrl(e.target.value)}
 | 
				
			||||||
 | 
					                    placeholder="https://example.com/feed.xml"
 | 
				
			||||||
 | 
					                    required
 | 
				
			||||||
 | 
					                  />
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <button type="submit" className="btn btn-success">
 | 
				
			||||||
 | 
					                  追加
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					              </form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <div style={{ marginTop: '24px' }}>
 | 
				
			||||||
 | 
					                <h3>フィード一覧 ({feeds.length}件)</h3>
 | 
				
			||||||
 | 
					                {feeds.length === 0 ? (
 | 
				
			||||||
 | 
					                  <p style={{ color: '#7f8c8d', textAlign: 'center', padding: '20px' }}>
 | 
				
			||||||
 | 
					                    フィードが登録されていません
 | 
				
			||||||
 | 
					                  </p>
 | 
				
			||||||
 | 
					                ) : (
 | 
				
			||||||
 | 
					                  <ul className="feeds-list">
 | 
				
			||||||
 | 
					                    {feeds.map((feed) => (
 | 
				
			||||||
 | 
					                      <li key={feed.id} className="feed-item">
 | 
				
			||||||
 | 
					                        <div className="feed-info">
 | 
				
			||||||
 | 
					                          <h3>{feed.title || 'タイトル未設定'}</h3>
 | 
				
			||||||
 | 
					                          <div className="url">{feed.url}</div>
 | 
				
			||||||
 | 
					                          <span className={`status ${feed.active ? 'active' : 'inactive'}`}>
 | 
				
			||||||
 | 
					                            {feed.active ? 'アクティブ' : '非アクティブ'}
 | 
				
			||||||
 | 
					                          </span>
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                        <div className="feed-actions">
 | 
				
			||||||
 | 
					                          <button
 | 
				
			||||||
 | 
					                            className={`btn ${feed.active ? 'btn-warning' : 'btn-success'}`}
 | 
				
			||||||
 | 
					                            onClick={() => toggleFeed(feed.id, !feed.active)}
 | 
				
			||||||
 | 
					                          >
 | 
				
			||||||
 | 
					                            {feed.active ? '無効化' : '有効化'}
 | 
				
			||||||
 | 
					                          </button>
 | 
				
			||||||
 | 
					                          <button
 | 
				
			||||||
 | 
					                            className="btn btn-danger"
 | 
				
			||||||
 | 
					                            onClick={() => deleteFeed(feed.id)}
 | 
				
			||||||
 | 
					                          >
 | 
				
			||||||
 | 
					                            削除
 | 
				
			||||||
 | 
					                          </button>
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                      </li>
 | 
				
			||||||
 | 
					                    ))}
 | 
				
			||||||
 | 
					                  </ul>
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {activeTab === 'env' && (
 | 
				
			||||||
 | 
					            <>
 | 
				
			||||||
 | 
					              <h3>環境変数設定</h3>
 | 
				
			||||||
 | 
					              <p style={{ marginBottom: '20px', color: '#7f8c8d' }}>
 | 
				
			||||||
 | 
					                現在の環境変数設定を表示しています。機密情報は***SET***と表示されます。
 | 
				
			||||||
 | 
					              </p>
 | 
				
			||||||
 | 
					              
 | 
				
			||||||
 | 
					              <ul className="env-list">
 | 
				
			||||||
 | 
					                {Object.entries(envVars).map(([key, value]) => (
 | 
				
			||||||
 | 
					                  <li key={key} className="env-item">
 | 
				
			||||||
 | 
					                    <div className="env-key">{key}</div>
 | 
				
			||||||
 | 
					                    <div className="env-value">
 | 
				
			||||||
 | 
					                      {value === undefined ? '未設定' : value}
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                  </li>
 | 
				
			||||||
 | 
					                ))}
 | 
				
			||||||
 | 
					              </ul>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <div style={{ marginTop: '20px', padding: '16px', background: '#f8f9fa', borderRadius: '4px' }}>
 | 
				
			||||||
 | 
					                <h4>環境変数の設定方法</h4>
 | 
				
			||||||
 | 
					                <p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}>
 | 
				
			||||||
 | 
					                  環境変数を変更するには、.envファイルを編集するか、システムの環境変数を設定してください。
 | 
				
			||||||
 | 
					                  変更後はサーバーの再起動が必要です。
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default App;
 | 
				
			||||||
							
								
								
									
										278
									
								
								admin-panel/src/index.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										278
									
								
								admin-panel/src/index.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,278 @@
 | 
				
			|||||||
 | 
					* {
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  padding: 0;
 | 
				
			||||||
 | 
					  box-sizing: border-box;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body {
 | 
				
			||||||
 | 
					  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
 | 
				
			||||||
 | 
					    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
 | 
				
			||||||
 | 
					    sans-serif;
 | 
				
			||||||
 | 
					  -webkit-font-smoothing: antialiased;
 | 
				
			||||||
 | 
					  -moz-osx-font-smoothing: grayscale;
 | 
				
			||||||
 | 
					  background-color: #f5f5f5;
 | 
				
			||||||
 | 
					  color: #333;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.container {
 | 
				
			||||||
 | 
					  max-width: 1200px;
 | 
				
			||||||
 | 
					  margin: 0 auto;
 | 
				
			||||||
 | 
					  padding: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.header {
 | 
				
			||||||
 | 
					  background: #fff;
 | 
				
			||||||
 | 
					  padding: 20px;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 | 
				
			||||||
 | 
					  margin-bottom: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.header h1 {
 | 
				
			||||||
 | 
					  color: #2c3e50;
 | 
				
			||||||
 | 
					  margin-bottom: 10px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.header .subtitle {
 | 
				
			||||||
 | 
					  color: #7f8c8d;
 | 
				
			||||||
 | 
					  font-size: 14px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.card {
 | 
				
			||||||
 | 
					  background: #fff;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 | 
				
			||||||
 | 
					  margin-bottom: 20px;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.card-header {
 | 
				
			||||||
 | 
					  padding: 20px;
 | 
				
			||||||
 | 
					  border-bottom: 1px solid #eee;
 | 
				
			||||||
 | 
					  background: #f8f9fa;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.card-header h2 {
 | 
				
			||||||
 | 
					  color: #2c3e50;
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  font-size: 18px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.card-content {
 | 
				
			||||||
 | 
					  padding: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn {
 | 
				
			||||||
 | 
					  padding: 8px 16px;
 | 
				
			||||||
 | 
					  border: none;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  font-size: 14px;
 | 
				
			||||||
 | 
					  transition: background-color 0.2s;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-primary {
 | 
				
			||||||
 | 
					  background: #3498db;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-primary:hover {
 | 
				
			||||||
 | 
					  background: #2980b9;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-danger {
 | 
				
			||||||
 | 
					  background: #e74c3c;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-danger:hover {
 | 
				
			||||||
 | 
					  background: #c0392b;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-success {
 | 
				
			||||||
 | 
					  background: #27ae60;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-success:hover {
 | 
				
			||||||
 | 
					  background: #219a52;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-warning {
 | 
				
			||||||
 | 
					  background: #f39c12;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-warning:hover {
 | 
				
			||||||
 | 
					  background: #e67e22;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn:disabled {
 | 
				
			||||||
 | 
					  opacity: 0.6;
 | 
				
			||||||
 | 
					  cursor: not-allowed;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.input {
 | 
				
			||||||
 | 
					  padding: 8px 12px;
 | 
				
			||||||
 | 
					  border: 1px solid #ddd;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  font-size: 14px;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.input:focus {
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					  border-color: #3498db;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.form-group {
 | 
				
			||||||
 | 
					  margin-bottom: 16px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.form-group label {
 | 
				
			||||||
 | 
					  display: block;
 | 
				
			||||||
 | 
					  margin-bottom: 4px;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #2c3e50;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.stats-grid {
 | 
				
			||||||
 | 
					  display: grid;
 | 
				
			||||||
 | 
					  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
 | 
				
			||||||
 | 
					  gap: 16px;
 | 
				
			||||||
 | 
					  margin-bottom: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.stat-card {
 | 
				
			||||||
 | 
					  background: #fff;
 | 
				
			||||||
 | 
					  padding: 20px;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.stat-card .value {
 | 
				
			||||||
 | 
					  font-size: 32px;
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					  color: #3498db;
 | 
				
			||||||
 | 
					  margin-bottom: 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.stat-card .label {
 | 
				
			||||||
 | 
					  color: #7f8c8d;
 | 
				
			||||||
 | 
					  font-size: 14px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feeds-list {
 | 
				
			||||||
 | 
					  list-style: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-item {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  padding: 16px;
 | 
				
			||||||
 | 
					  border-bottom: 1px solid #eee;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-item:last-child {
 | 
				
			||||||
 | 
					  border-bottom: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-info h3 {
 | 
				
			||||||
 | 
					  margin: 0 0 4px 0;
 | 
				
			||||||
 | 
					  color: #2c3e50;
 | 
				
			||||||
 | 
					  font-size: 16px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-info .url {
 | 
				
			||||||
 | 
					  color: #7f8c8d;
 | 
				
			||||||
 | 
					  font-size: 14px;
 | 
				
			||||||
 | 
					  word-break: break-all;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-info .status {
 | 
				
			||||||
 | 
					  font-size: 12px;
 | 
				
			||||||
 | 
					  padding: 2px 8px;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  margin-top: 4px;
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-info .status.active {
 | 
				
			||||||
 | 
					  background: #d4edda;
 | 
				
			||||||
 | 
					  color: #155724;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-info .status.inactive {
 | 
				
			||||||
 | 
					  background: #f8d7da;
 | 
				
			||||||
 | 
					  color: #721c24;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  gap: 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.env-list {
 | 
				
			||||||
 | 
					  list-style: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.env-item {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  padding: 12px 0;
 | 
				
			||||||
 | 
					  border-bottom: 1px solid #eee;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.env-item:last-child {
 | 
				
			||||||
 | 
					  border-bottom: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.env-key {
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					  color: #2c3e50;
 | 
				
			||||||
 | 
					  min-width: 200px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.env-value {
 | 
				
			||||||
 | 
					  color: #7f8c8d;
 | 
				
			||||||
 | 
					  font-family: monospace;
 | 
				
			||||||
 | 
					  background: #f8f9fa;
 | 
				
			||||||
 | 
					  padding: 4px 8px;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.loading {
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					  padding: 40px;
 | 
				
			||||||
 | 
					  color: #7f8c8d;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.error {
 | 
				
			||||||
 | 
					  background: #f8d7da;
 | 
				
			||||||
 | 
					  color: #721c24;
 | 
				
			||||||
 | 
					  padding: 12px;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  margin-bottom: 16px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.success {
 | 
				
			||||||
 | 
					  background: #d4edda;
 | 
				
			||||||
 | 
					  color: #155724;
 | 
				
			||||||
 | 
					  padding: 12px;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  margin-bottom: 16px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.add-feed-form {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  gap: 12px;
 | 
				
			||||||
 | 
					  align-items: end;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.add-feed-form .form-group {
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					  margin-bottom: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										10
									
								
								admin-panel/src/main.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								admin-panel/src/main.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					import React from 'react'
 | 
				
			||||||
 | 
					import ReactDOM from 'react-dom/client'
 | 
				
			||||||
 | 
					import App from './App.tsx'
 | 
				
			||||||
 | 
					import './index.css'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ReactDOM.createRoot(document.getElementById('root')!).render(
 | 
				
			||||||
 | 
					  <React.StrictMode>
 | 
				
			||||||
 | 
					    <App />
 | 
				
			||||||
 | 
					  </React.StrictMode>,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
							
								
								
									
										21
									
								
								admin-panel/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								admin-panel/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "compilerOptions": {
 | 
				
			||||||
 | 
					    "target": "ES2020",
 | 
				
			||||||
 | 
					    "useDefineForClassFields": true,
 | 
				
			||||||
 | 
					    "lib": ["ES2020", "DOM", "DOM.Iterable"],
 | 
				
			||||||
 | 
					    "module": "ESNext",
 | 
				
			||||||
 | 
					    "skipLibCheck": true,
 | 
				
			||||||
 | 
					    "moduleResolution": "bundler",
 | 
				
			||||||
 | 
					    "allowImportingTsExtensions": true,
 | 
				
			||||||
 | 
					    "resolveJsonModule": true,
 | 
				
			||||||
 | 
					    "isolatedModules": true,
 | 
				
			||||||
 | 
					    "noEmit": true,
 | 
				
			||||||
 | 
					    "jsx": "react-jsx",
 | 
				
			||||||
 | 
					    "strict": true,
 | 
				
			||||||
 | 
					    "noUnusedLocals": true,
 | 
				
			||||||
 | 
					    "noUnusedParameters": true,
 | 
				
			||||||
 | 
					    "noFallthroughCasesInSwitch": true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "include": ["src"],
 | 
				
			||||||
 | 
					  "references": [{ "path": "./tsconfig.node.json" }]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										10
									
								
								admin-panel/tsconfig.node.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								admin-panel/tsconfig.node.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "compilerOptions": {
 | 
				
			||||||
 | 
					    "composite": true,
 | 
				
			||||||
 | 
					    "skipLibCheck": true,
 | 
				
			||||||
 | 
					    "module": "ESNext",
 | 
				
			||||||
 | 
					    "moduleResolution": "bundler",
 | 
				
			||||||
 | 
					    "allowSyntheticDefaultImports": true
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "include": ["vite.config.ts"]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										9
									
								
								admin-panel/vite.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								admin-panel/vite.config.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					import { defineConfig } from 'vite'
 | 
				
			||||||
 | 
					import react from '@vitejs/plugin-react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default defineConfig({
 | 
				
			||||||
 | 
					  plugins: [react()],
 | 
				
			||||||
 | 
					  build: {
 | 
				
			||||||
 | 
					    outDir: 'dist',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
							
								
								
									
										343
									
								
								admin-server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										343
									
								
								admin-server.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,343 @@
 | 
				
			|||||||
 | 
					import { Hono } from "hono";
 | 
				
			||||||
 | 
					import { serve } from "@hono/node-server";
 | 
				
			||||||
 | 
					import { basicAuth } from "hono/basic-auth";
 | 
				
			||||||
 | 
					import path from "path";
 | 
				
			||||||
 | 
					import { config, validateConfig } from "./services/config.js";
 | 
				
			||||||
 | 
					import { 
 | 
				
			||||||
 | 
					  getAllFeedsIncludingInactive,
 | 
				
			||||||
 | 
					  deleteFeed,
 | 
				
			||||||
 | 
					  toggleFeedActive,
 | 
				
			||||||
 | 
					  getFeedByUrl,
 | 
				
			||||||
 | 
					  fetchAllEpisodes,
 | 
				
			||||||
 | 
					  fetchEpisodesWithArticles,
 | 
				
			||||||
 | 
					} from "./services/database.js";
 | 
				
			||||||
 | 
					import { batchProcess, addNewFeedUrl } from "./scripts/fetch_and_generate.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Validate configuration on startup
 | 
				
			||||||
 | 
					try {
 | 
				
			||||||
 | 
					  validateConfig();
 | 
				
			||||||
 | 
					  console.log("Admin panel configuration validated successfully");
 | 
				
			||||||
 | 
					} catch (error) {
 | 
				
			||||||
 | 
					  console.error("Admin panel configuration validation failed:", error);
 | 
				
			||||||
 | 
					  process.exit(1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const app = new Hono();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Basic Authentication middleware (if credentials are provided)
 | 
				
			||||||
 | 
					if (config.admin.username && config.admin.password) {
 | 
				
			||||||
 | 
					  app.use("*", basicAuth({
 | 
				
			||||||
 | 
					    username: config.admin.username,
 | 
				
			||||||
 | 
					    password: config.admin.password,
 | 
				
			||||||
 | 
					  }));
 | 
				
			||||||
 | 
					  console.log("🔐 Admin panel authentication enabled");
 | 
				
			||||||
 | 
					} else {
 | 
				
			||||||
 | 
					  console.log("⚠️  Admin panel running without authentication");
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Environment variables management
 | 
				
			||||||
 | 
					app.get("/api/admin/env", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const envVars = {
 | 
				
			||||||
 | 
					      // OpenAI Configuration
 | 
				
			||||||
 | 
					      OPENAI_API_KEY: import.meta.env["OPENAI_API_KEY"] ? "***SET***" : undefined,
 | 
				
			||||||
 | 
					      OPENAI_API_ENDPOINT: import.meta.env["OPENAI_API_ENDPOINT"],
 | 
				
			||||||
 | 
					      OPENAI_MODEL_NAME: import.meta.env["OPENAI_MODEL_NAME"],
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // VOICEVOX Configuration
 | 
				
			||||||
 | 
					      VOICEVOX_HOST: import.meta.env["VOICEVOX_HOST"],
 | 
				
			||||||
 | 
					      VOICEVOX_STYLE_ID: import.meta.env["VOICEVOX_STYLE_ID"],
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Podcast Configuration
 | 
				
			||||||
 | 
					      PODCAST_TITLE: import.meta.env["PODCAST_TITLE"],
 | 
				
			||||||
 | 
					      PODCAST_LINK: import.meta.env["PODCAST_LINK"],
 | 
				
			||||||
 | 
					      PODCAST_DESCRIPTION: import.meta.env["PODCAST_DESCRIPTION"],
 | 
				
			||||||
 | 
					      PODCAST_LANGUAGE: import.meta.env["PODCAST_LANGUAGE"],
 | 
				
			||||||
 | 
					      PODCAST_AUTHOR: import.meta.env["PODCAST_AUTHOR"],
 | 
				
			||||||
 | 
					      PODCAST_CATEGORIES: import.meta.env["PODCAST_CATEGORIES"],
 | 
				
			||||||
 | 
					      PODCAST_TTL: import.meta.env["PODCAST_TTL"],
 | 
				
			||||||
 | 
					      PODCAST_BASE_URL: import.meta.env["PODCAST_BASE_URL"],
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // Admin Configuration
 | 
				
			||||||
 | 
					      ADMIN_PORT: import.meta.env["ADMIN_PORT"],
 | 
				
			||||||
 | 
					      ADMIN_USERNAME: import.meta.env["ADMIN_USERNAME"] ? "***SET***" : undefined,
 | 
				
			||||||
 | 
					      ADMIN_PASSWORD: import.meta.env["ADMIN_PASSWORD"] ? "***SET***" : undefined,
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // File Configuration
 | 
				
			||||||
 | 
					      FEED_URLS_FILE: import.meta.env["FEED_URLS_FILE"],
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    return c.json(envVars);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error fetching environment variables:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to fetch environment variables" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Feed management API endpoints
 | 
				
			||||||
 | 
					app.get("/api/admin/feeds", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const feeds = await getAllFeedsIncludingInactive();
 | 
				
			||||||
 | 
					    return c.json(feeds);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error fetching feeds:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to fetch feeds" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.post("/api/admin/feeds", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const { feedUrl } = await c.req.json<{ feedUrl: string }>();
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (!feedUrl || typeof feedUrl !== "string" || !feedUrl.startsWith('http')) {
 | 
				
			||||||
 | 
					      return c.json({ error: "Valid feed URL is required" }, 400);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    console.log("➕ Admin adding new feed URL:", feedUrl);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Check if feed already exists
 | 
				
			||||||
 | 
					    const existingFeed = await getFeedByUrl(feedUrl);
 | 
				
			||||||
 | 
					    if (existingFeed) {
 | 
				
			||||||
 | 
					      return c.json({ 
 | 
				
			||||||
 | 
					        result: "EXISTS", 
 | 
				
			||||||
 | 
					        message: "Feed URL already exists",
 | 
				
			||||||
 | 
					        feed: existingFeed 
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Add new feed
 | 
				
			||||||
 | 
					    await addNewFeedUrl(feedUrl);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    return c.json({ 
 | 
				
			||||||
 | 
					      result: "CREATED", 
 | 
				
			||||||
 | 
					      message: "Feed URL added successfully",
 | 
				
			||||||
 | 
					      feedUrl 
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error adding feed:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to add feed" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.delete("/api/admin/feeds/:id", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const feedId = c.req.param("id");
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (!feedId || feedId.trim() === "") {
 | 
				
			||||||
 | 
					      return c.json({ error: "Feed ID is required" }, 400);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    console.log("🗑️  Admin deleting feed ID:", feedId);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const deleted = await deleteFeed(feedId);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (deleted) {
 | 
				
			||||||
 | 
					      return c.json({ 
 | 
				
			||||||
 | 
					        result: "DELETED", 
 | 
				
			||||||
 | 
					        message: "Feed deleted successfully",
 | 
				
			||||||
 | 
					        feedId 
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      return c.json({ error: "Feed not found" }, 404);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error deleting feed:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to delete feed" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.patch("/api/admin/feeds/:id/toggle", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const feedId = c.req.param("id");
 | 
				
			||||||
 | 
					    const { active } = await c.req.json<{ active: boolean }>();
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (!feedId || feedId.trim() === "") {
 | 
				
			||||||
 | 
					      return c.json({ error: "Feed ID is required" }, 400);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (typeof active !== "boolean") {
 | 
				
			||||||
 | 
					      return c.json({ error: "Active status must be a boolean" }, 400);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    console.log(`🔄 Admin toggling feed ${feedId} to ${active ? "active" : "inactive"}`);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const updated = await toggleFeedActive(feedId, active);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (updated) {
 | 
				
			||||||
 | 
					      return c.json({ 
 | 
				
			||||||
 | 
					        result: "UPDATED", 
 | 
				
			||||||
 | 
					        message: `Feed ${active ? "activated" : "deactivated"} successfully`,
 | 
				
			||||||
 | 
					        feedId,
 | 
				
			||||||
 | 
					        active
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      return c.json({ error: "Feed not found" }, 404);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error toggling feed active status:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to toggle feed status" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Episodes management
 | 
				
			||||||
 | 
					app.get("/api/admin/episodes", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const episodes = await fetchEpisodesWithArticles();
 | 
				
			||||||
 | 
					    return c.json(episodes);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error fetching episodes:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to fetch episodes" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.get("/api/admin/episodes/simple", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const episodes = await fetchAllEpisodes();
 | 
				
			||||||
 | 
					    return c.json(episodes);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error fetching simple episodes:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to fetch episodes" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// System management
 | 
				
			||||||
 | 
					app.get("/api/admin/stats", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const feeds = await getAllFeedsIncludingInactive();
 | 
				
			||||||
 | 
					    const episodes = await fetchAllEpisodes();
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const stats = {
 | 
				
			||||||
 | 
					      totalFeeds: feeds.length,
 | 
				
			||||||
 | 
					      activeFeeds: feeds.filter(f => f.active).length,
 | 
				
			||||||
 | 
					      inactiveFeeds: feeds.filter(f => !f.active).length,
 | 
				
			||||||
 | 
					      totalEpisodes: episodes.length,
 | 
				
			||||||
 | 
					      lastUpdated: new Date().toISOString(),
 | 
				
			||||||
 | 
					      adminPort: config.admin.port,
 | 
				
			||||||
 | 
					      authEnabled: !!(config.admin.username && config.admin.password),
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    return c.json(stats);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error fetching admin stats:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to fetch statistics" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.post("/api/admin/batch/trigger", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    console.log("🚀 Manual batch process triggered via admin panel");
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Run batch process in background
 | 
				
			||||||
 | 
					    runBatchProcess().catch(error => {
 | 
				
			||||||
 | 
					      console.error("❌ Manual admin batch process failed:", error);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    return c.json({ 
 | 
				
			||||||
 | 
					      result: "TRIGGERED",
 | 
				
			||||||
 | 
					      message: "Batch process started in background",
 | 
				
			||||||
 | 
					      timestamp: new Date().toISOString()
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error triggering admin batch process:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to trigger batch process" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Static file handlers for admin panel UI
 | 
				
			||||||
 | 
					app.get("/assets/*", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const filePath = path.join(config.paths.adminBuildDir, c.req.path);
 | 
				
			||||||
 | 
					    const file = Bun.file(filePath);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (await file.exists()) {
 | 
				
			||||||
 | 
					      const contentType = filePath.endsWith(".js")
 | 
				
			||||||
 | 
					        ? "application/javascript"
 | 
				
			||||||
 | 
					        : filePath.endsWith(".css")
 | 
				
			||||||
 | 
					          ? "text/css"
 | 
				
			||||||
 | 
					          : "application/octet-stream";
 | 
				
			||||||
 | 
					      const blob = await file.arrayBuffer();
 | 
				
			||||||
 | 
					      return c.body(blob, 200, { "Content-Type": contentType });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return c.notFound();
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error serving admin asset:", error);
 | 
				
			||||||
 | 
					    return c.notFound();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Admin panel frontend
 | 
				
			||||||
 | 
					async function serveAdminIndex(c: any) {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const indexPath = path.join(config.paths.adminBuildDir, "index.html");
 | 
				
			||||||
 | 
					    const file = Bun.file(indexPath);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (await file.exists()) {
 | 
				
			||||||
 | 
					      const blob = await file.arrayBuffer();
 | 
				
			||||||
 | 
					      return c.body(blob, 200, { "Content-Type": "text/html; charset=utf-8" });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Fallback to simple HTML if admin panel is not built
 | 
				
			||||||
 | 
					    return c.html(`
 | 
				
			||||||
 | 
					      <!DOCTYPE html>
 | 
				
			||||||
 | 
					      <html>
 | 
				
			||||||
 | 
					        <head>
 | 
				
			||||||
 | 
					          <title>Admin Panel</title>
 | 
				
			||||||
 | 
					          <style>
 | 
				
			||||||
 | 
					            body { font-family: Arial, sans-serif; margin: 40px; }
 | 
				
			||||||
 | 
					            .container { max-width: 800px; margin: 0 auto; }
 | 
				
			||||||
 | 
					            .error { color: #d32f2f; background: #ffebee; padding: 16px; border-radius: 4px; }
 | 
				
			||||||
 | 
					          </style>
 | 
				
			||||||
 | 
					        </head>
 | 
				
			||||||
 | 
					        <body>
 | 
				
			||||||
 | 
					          <div class="container">
 | 
				
			||||||
 | 
					            <h1>Admin Panel</h1>
 | 
				
			||||||
 | 
					            <div class="error">
 | 
				
			||||||
 | 
					              <h3>Admin UI Not Built</h3>
 | 
				
			||||||
 | 
					              <p>The admin panel UI has not been built yet.</p>
 | 
				
			||||||
 | 
					              <p>For now, you can use the API endpoints directly:</p>
 | 
				
			||||||
 | 
					              <ul>
 | 
				
			||||||
 | 
					                <li>GET /api/admin/feeds - List all feeds</li>
 | 
				
			||||||
 | 
					                <li>POST /api/admin/feeds - Add new feed</li>
 | 
				
			||||||
 | 
					                <li>DELETE /api/admin/feeds/:id - Delete feed</li>
 | 
				
			||||||
 | 
					                <li>PATCH /api/admin/feeds/:id/toggle - Toggle feed active status</li>
 | 
				
			||||||
 | 
					                <li>GET /api/admin/env - View environment variables</li>
 | 
				
			||||||
 | 
					                <li>GET /api/admin/stats - View system statistics</li>
 | 
				
			||||||
 | 
					                <li>POST /api/admin/batch/trigger - Trigger batch process</li>
 | 
				
			||||||
 | 
					              </ul>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </body>
 | 
				
			||||||
 | 
					      </html>
 | 
				
			||||||
 | 
					    `);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error serving admin index.html:", error);
 | 
				
			||||||
 | 
					    return c.text("Internal server error", 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.get("/", serveAdminIndex);
 | 
				
			||||||
 | 
					app.get("/index.html", serveAdminIndex);
 | 
				
			||||||
 | 
					app.get("*", serveAdminIndex);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Utility functions
 | 
				
			||||||
 | 
					async function runBatchProcess(): Promise<void> {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await batchProcess();
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Admin batch process failed:", error);
 | 
				
			||||||
 | 
					    throw error;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Start admin server
 | 
				
			||||||
 | 
					serve(
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    fetch: app.fetch,
 | 
				
			||||||
 | 
					    port: config.admin.port,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  (info) => {
 | 
				
			||||||
 | 
					    console.log(`🔧 Admin panel running on http://localhost:${info.port}`);
 | 
				
			||||||
 | 
					    console.log(`📊 Admin authentication: ${config.admin.username && config.admin.password ? "enabled" : "disabled"}`);
 | 
				
			||||||
 | 
					    console.log(`🗄️  Database: ${config.paths.dbPath}`);
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
@@ -3,8 +3,11 @@
 | 
				
			|||||||
  "version": "1.0.0",
 | 
					  "version": "1.0.0",
 | 
				
			||||||
  "scripts": {
 | 
					  "scripts": {
 | 
				
			||||||
    "start": "bun run build:frontend && bun run server.ts",
 | 
					    "start": "bun run build:frontend && bun run server.ts",
 | 
				
			||||||
 | 
					    "admin": "bun run build:admin && bun run admin-server.ts",
 | 
				
			||||||
    "build:frontend": "cd frontend && bun vite build",
 | 
					    "build:frontend": "cd frontend && bun vite build",
 | 
				
			||||||
    "dev:frontend": "cd frontend && bun vite dev"
 | 
					    "build:admin": "cd admin-panel && bun install && bun run build",
 | 
				
			||||||
 | 
					    "dev:frontend": "cd frontend && bun vite dev",
 | 
				
			||||||
 | 
					    "dev:admin": "cd admin-panel && bun vite dev"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
    "@aws-sdk/client-polly": "^3.823.0",
 | 
					    "@aws-sdk/client-polly": "^3.823.0",
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										142
									
								
								server.ts
									
									
									
									
									
								
							
							
						
						
									
										142
									
								
								server.ts
									
									
									
									
									
								
							@@ -2,13 +2,7 @@ import { Hono } from "hono";
 | 
				
			|||||||
import { serve } from "@hono/node-server";
 | 
					import { serve } from "@hono/node-server";
 | 
				
			||||||
import path from "path";
 | 
					import path from "path";
 | 
				
			||||||
import { config, validateConfig } from "./services/config.js";
 | 
					import { config, validateConfig } from "./services/config.js";
 | 
				
			||||||
import { 
 | 
					import { batchProcess } from "./scripts/fetch_and_generate.js";
 | 
				
			||||||
  fetchAllEpisodes, 
 | 
					 | 
				
			||||||
  fetchEpisodesWithArticles,
 | 
					 | 
				
			||||||
  getAllFeeds,
 | 
					 | 
				
			||||||
  getFeedByUrl
 | 
					 | 
				
			||||||
} from "./services/database.js";
 | 
					 | 
				
			||||||
import { batchProcess, addNewFeedUrl } from "./scripts/fetch_and_generate.js";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Validate configuration on startup
 | 
					// Validate configuration on startup
 | 
				
			||||||
try {
 | 
					try {
 | 
				
			||||||
@@ -21,133 +15,6 @@ try {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const app = new Hono();
 | 
					const app = new Hono();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// API routes
 | 
					 | 
				
			||||||
app.get("/api/feeds", async (c) => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    const feeds = await getAllFeeds();
 | 
					 | 
				
			||||||
    return c.json(feeds);
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    console.error("Error fetching feeds:", error);
 | 
					 | 
				
			||||||
    return c.json({ error: "Failed to fetch feeds" }, 500);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
app.post("/api/feeds", async (c) => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    const { feedUrl } = await c.req.json<{ feedUrl: string }>();
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    if (!feedUrl || typeof feedUrl !== "string" || !feedUrl.startsWith('http')) {
 | 
					 | 
				
			||||||
      return c.json({ error: "Valid feed URL is required" }, 400);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    console.log("➕ Adding new feed URL:", feedUrl);
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Check if feed already exists
 | 
					 | 
				
			||||||
    const existingFeed = await getFeedByUrl(feedUrl);
 | 
					 | 
				
			||||||
    if (existingFeed) {
 | 
					 | 
				
			||||||
      return c.json({ 
 | 
					 | 
				
			||||||
        result: "EXISTS", 
 | 
					 | 
				
			||||||
        message: "Feed URL already exists",
 | 
					 | 
				
			||||||
        feed: existingFeed 
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Add new feed
 | 
					 | 
				
			||||||
    await addNewFeedUrl(feedUrl);
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    return c.json({ 
 | 
					 | 
				
			||||||
      result: "CREATED", 
 | 
					 | 
				
			||||||
      message: "Feed URL added successfully",
 | 
					 | 
				
			||||||
      feedUrl 
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    console.error("Error adding feed:", error);
 | 
					 | 
				
			||||||
    return c.json({ error: "Failed to add feed" }, 500);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
app.get("/api/episodes", async (c) => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    const episodes = await fetchEpisodesWithArticles();
 | 
					 | 
				
			||||||
    return c.json(episodes);
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    console.error("Error fetching episodes:", error);
 | 
					 | 
				
			||||||
    return c.json({ error: "Failed to fetch episodes" }, 500);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
app.get("/api/episodes/simple", async (c) => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    const episodes = await fetchAllEpisodes();
 | 
					 | 
				
			||||||
    return c.json(episodes);
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    console.error("Error fetching simple episodes:", error);
 | 
					 | 
				
			||||||
    return c.json({ error: "Failed to fetch episodes" }, 500);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
app.post("/api/episodes/:id/regenerate", async (c) => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    const id = c.req.param("id");
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    if (!id || id.trim() === "") {
 | 
					 | 
				
			||||||
      return c.json({ error: "Episode ID is required" }, 400);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    console.log("🔄 Regeneration requested for episode ID:", id);
 | 
					 | 
				
			||||||
    // TODO: Implement regeneration logic
 | 
					 | 
				
			||||||
    return c.json({ 
 | 
					 | 
				
			||||||
      result: "PENDING", 
 | 
					 | 
				
			||||||
      episodeId: id,
 | 
					 | 
				
			||||||
      status: "pending",
 | 
					 | 
				
			||||||
      message: "Regeneration feature will be implemented in a future update"
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    console.error("Error requesting regeneration:", error);
 | 
					 | 
				
			||||||
    return c.json({ error: "Failed to request regeneration" }, 500);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// New API endpoints for enhanced functionality
 | 
					 | 
				
			||||||
app.get("/api/stats", async (c) => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    const feeds = await getAllFeeds();
 | 
					 | 
				
			||||||
    const episodes = await fetchAllEpisodes();
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    const stats = {
 | 
					 | 
				
			||||||
      totalFeeds: feeds.length,
 | 
					 | 
				
			||||||
      activeFeeds: feeds.filter(f => f.active).length,
 | 
					 | 
				
			||||||
      totalEpisodes: episodes.length,
 | 
					 | 
				
			||||||
      lastUpdated: new Date().toISOString()
 | 
					 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    return c.json(stats);
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    console.error("Error fetching stats:", error);
 | 
					 | 
				
			||||||
    return c.json({ error: "Failed to fetch statistics" }, 500);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
app.post("/api/batch/trigger", async (c) => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    console.log("🚀 Manual batch process triggered via API");
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    // Run batch process in background
 | 
					 | 
				
			||||||
    runBatchProcess().catch(error => {
 | 
					 | 
				
			||||||
      console.error("❌ Manual batch process failed:", error);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    return c.json({ 
 | 
					 | 
				
			||||||
      result: "TRIGGERED",
 | 
					 | 
				
			||||||
      message: "Batch process started in background",
 | 
					 | 
				
			||||||
      timestamp: new Date().toISOString()
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    console.error("Error triggering batch process:", error);
 | 
					 | 
				
			||||||
    return c.json({ error: "Failed to trigger batch process" }, 500);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// 静的ファイルの処理
 | 
					// 静的ファイルの処理
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Static file handlers
 | 
					// Static file handlers
 | 
				
			||||||
@@ -216,13 +83,6 @@ app.get("/podcast.xml", async (c) => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Legacy endpoint - redirect to new one
 | 
					 | 
				
			||||||
app.post("/api/add-feed", async (c) => {
 | 
					 | 
				
			||||||
  return c.json({ 
 | 
					 | 
				
			||||||
    error: "This endpoint is deprecated. Use POST /api/feeds instead.",
 | 
					 | 
				
			||||||
    newEndpoint: "POST /api/feeds"
 | 
					 | 
				
			||||||
  }, 410);
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Frontend fallback routes
 | 
					// Frontend fallback routes
 | 
				
			||||||
async function serveIndex(c: any) {
 | 
					async function serveIndex(c: any) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -26,6 +26,13 @@ interface Config {
 | 
				
			|||||||
    baseUrl: string;
 | 
					    baseUrl: string;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
 | 
					  // Admin Panel Configuration
 | 
				
			||||||
 | 
					  admin: {
 | 
				
			||||||
 | 
					    port: number;
 | 
				
			||||||
 | 
					    username?: string;
 | 
				
			||||||
 | 
					    password?: string;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
  // File paths
 | 
					  // File paths
 | 
				
			||||||
  paths: {
 | 
					  paths: {
 | 
				
			||||||
    projectRoot: string;
 | 
					    projectRoot: string;
 | 
				
			||||||
@@ -34,6 +41,7 @@ interface Config {
 | 
				
			|||||||
    publicDir: string;
 | 
					    publicDir: string;
 | 
				
			||||||
    podcastAudioDir: string;
 | 
					    podcastAudioDir: string;
 | 
				
			||||||
    frontendBuildDir: string;
 | 
					    frontendBuildDir: string;
 | 
				
			||||||
 | 
					    adminBuildDir: string;
 | 
				
			||||||
    feedUrlsFile: string;
 | 
					    feedUrlsFile: string;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -78,6 +86,12 @@ function createConfig(): Config {
 | 
				
			|||||||
      baseUrl: getOptionalEnv("PODCAST_BASE_URL", "https://your-domain.com"),
 | 
					      baseUrl: getOptionalEnv("PODCAST_BASE_URL", "https://your-domain.com"),
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
 | 
					    admin: {
 | 
				
			||||||
 | 
					      port: parseInt(getOptionalEnv("ADMIN_PORT", "3001")),
 | 
				
			||||||
 | 
					      username: import.meta.env["ADMIN_USERNAME"],
 | 
				
			||||||
 | 
					      password: import.meta.env["ADMIN_PASSWORD"],
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
    paths: {
 | 
					    paths: {
 | 
				
			||||||
      projectRoot,
 | 
					      projectRoot,
 | 
				
			||||||
      dataDir,
 | 
					      dataDir,
 | 
				
			||||||
@@ -85,6 +99,7 @@ function createConfig(): Config {
 | 
				
			|||||||
      publicDir,
 | 
					      publicDir,
 | 
				
			||||||
      podcastAudioDir: path.join(publicDir, "podcast_audio"),
 | 
					      podcastAudioDir: path.join(publicDir, "podcast_audio"),
 | 
				
			||||||
      frontendBuildDir: path.join(projectRoot, "frontend", "dist"),
 | 
					      frontendBuildDir: path.join(projectRoot, "frontend", "dist"),
 | 
				
			||||||
 | 
					      adminBuildDir: path.join(projectRoot, "admin-panel", "dist"),
 | 
				
			||||||
      feedUrlsFile: path.join(projectRoot, getOptionalEnv("FEED_URLS_FILE", "feed_urls.txt")),
 | 
					      feedUrlsFile: path.join(projectRoot, getOptionalEnv("FEED_URLS_FILE", "feed_urls.txt")),
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -183,6 +183,71 @@ export async function getAllFeeds(): Promise<Feed[]> {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function getAllFeedsIncludingInactive(): Promise<Feed[]> {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const stmt = db.prepare(
 | 
				
			||||||
 | 
					      "SELECT * FROM feeds ORDER BY created_at DESC",
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    const rows = stmt.all() as any[];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return rows.map((row) => ({
 | 
				
			||||||
 | 
					      id: row.id,
 | 
				
			||||||
 | 
					      url: row.url,
 | 
				
			||||||
 | 
					      title: row.title,
 | 
				
			||||||
 | 
					      description: row.description,
 | 
				
			||||||
 | 
					      lastUpdated: row.last_updated,
 | 
				
			||||||
 | 
					      createdAt: row.created_at,
 | 
				
			||||||
 | 
					      active: Boolean(row.active),
 | 
				
			||||||
 | 
					    }));
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error getting all feeds including inactive:", error);
 | 
				
			||||||
 | 
					    throw error;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function deleteFeed(feedId: string): Promise<boolean> {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    // Start transaction
 | 
				
			||||||
 | 
					    db.exec("BEGIN TRANSACTION");
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Delete all episodes for articles belonging to this feed
 | 
				
			||||||
 | 
					    const deleteEpisodesStmt = db.prepare(`
 | 
				
			||||||
 | 
					      DELETE FROM episodes 
 | 
				
			||||||
 | 
					      WHERE article_id IN (
 | 
				
			||||||
 | 
					        SELECT id FROM articles WHERE feed_id = ?
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    `);
 | 
				
			||||||
 | 
					    deleteEpisodesStmt.run(feedId);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Delete all articles for this feed
 | 
				
			||||||
 | 
					    const deleteArticlesStmt = db.prepare("DELETE FROM articles WHERE feed_id = ?");
 | 
				
			||||||
 | 
					    deleteArticlesStmt.run(feedId);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // Delete the feed itself
 | 
				
			||||||
 | 
					    const deleteFeedStmt = db.prepare("DELETE FROM feeds WHERE id = ?");
 | 
				
			||||||
 | 
					    const result = deleteFeedStmt.run(feedId);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    db.exec("COMMIT");
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    return result.changes > 0;
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    db.exec("ROLLBACK");
 | 
				
			||||||
 | 
					    console.error("Error deleting feed:", error);
 | 
				
			||||||
 | 
					    throw error;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function toggleFeedActive(feedId: string, active: boolean): Promise<boolean> {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const stmt = db.prepare("UPDATE feeds SET active = ? WHERE id = ?");
 | 
				
			||||||
 | 
					    const result = stmt.run(active ? 1 : 0, feedId);
 | 
				
			||||||
 | 
					    return result.changes > 0;
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error toggling feed active status:", error);
 | 
				
			||||||
 | 
					    throw error;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Article management functions
 | 
					// Article management functions
 | 
				
			||||||
export async function saveArticle(
 | 
					export async function saveArticle(
 | 
				
			||||||
  article: Omit<Article, "id" | "discoveredAt">,
 | 
					  article: Omit<Article, "id" | "discoveredAt">,
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user