Add admin panel
This commit is contained in:
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',
|
||||
},
|
||||
})
|
Reference in New Issue
Block a user