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