Apply formatting

This commit is contained in:
2025-06-08 15:21:58 +09:00
parent b5ff912fcb
commit a728ebb66c
28 changed files with 1809 additions and 1137 deletions

View File

@ -13,6 +13,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Admin panel development**: `bun run dev:admin` - **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`
- **Format code**: `bun run format`
- **Check format**: `bun run format:check`
- **Lint code**: `bun run lint`
- **Fix lint issues**: `bun run lint:fix`
- **Check all (format + lint)**: `bun run check`
- **Fix all issues**: `bun run check:fix`
## Architecture Overview ## Architecture Overview

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
interface Feed { interface Feed {
id: string; id: string;
@ -38,7 +38,7 @@ interface FeedRequest {
url: string; url: string;
requestedBy?: string; requestedBy?: string;
requestMessage?: string; requestMessage?: string;
status: 'pending' | 'approved' | 'rejected'; status: "pending" | "approved" | "rejected";
createdAt: string; createdAt: string;
reviewedAt?: string; reviewedAt?: string;
reviewedBy?: string; reviewedBy?: string;
@ -53,10 +53,16 @@ function App() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [newFeedUrl, setNewFeedUrl] = useState(''); const [newFeedUrl, setNewFeedUrl] = useState("");
const [requestFilter, setRequestFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all'); const [requestFilter, setRequestFilter] = useState<
const [approvalNotes, setApprovalNotes] = useState<{[key: string]: string}>({}); "all" | "pending" | "approved" | "rejected"
const [activeTab, setActiveTab] = useState<'dashboard' | 'feeds' | 'env' | 'batch' | 'requests'>('dashboard'); >("all");
const [approvalNotes, setApprovalNotes] = useState<{ [key: string]: string }>(
{},
);
const [activeTab, setActiveTab] = useState<
"dashboard" | "feeds" | "env" | "batch" | "requests"
>("dashboard");
useEffect(() => { useEffect(() => {
loadData(); loadData();
@ -66,21 +72,21 @@ function App() {
setLoading(true); setLoading(true);
try { try {
const [feedsRes, statsRes, envRes, requestsRes] = await Promise.all([ const [feedsRes, statsRes, envRes, requestsRes] = await Promise.all([
fetch('/api/admin/feeds'), fetch("/api/admin/feeds"),
fetch('/api/admin/stats'), fetch("/api/admin/stats"),
fetch('/api/admin/env'), fetch("/api/admin/env"),
fetch('/api/admin/feed-requests') fetch("/api/admin/feed-requests"),
]); ]);
if (!feedsRes.ok || !statsRes.ok || !envRes.ok || !requestsRes.ok) { if (!feedsRes.ok || !statsRes.ok || !envRes.ok || !requestsRes.ok) {
throw new Error('Failed to load data'); throw new Error("Failed to load data");
} }
const [feedsData, statsData, envData, requestsData] = await Promise.all([ const [feedsData, statsData, envData, requestsData] = await Promise.all([
feedsRes.json(), feedsRes.json(),
statsRes.json(), statsRes.json(),
envRes.json(), envRes.json(),
requestsRes.json() requestsRes.json(),
]); ]);
setFeeds(feedsData); setFeeds(feedsData);
@ -89,8 +95,8 @@ function App() {
setFeedRequests(requestsData); setFeedRequests(requestsData);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError('データの読み込みに失敗しました'); setError("データの読み込みに失敗しました");
console.error('Error loading data:', err); console.error("Error loading data:", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -101,35 +107,39 @@ function App() {
if (!newFeedUrl.trim()) return; if (!newFeedUrl.trim()) return;
try { try {
const res = await fetch('/api/admin/feeds', { const res = await fetch("/api/admin/feeds", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feedUrl: newFeedUrl }) body: JSON.stringify({ feedUrl: newFeedUrl }),
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
setSuccess(data.message); setSuccess(data.message);
setNewFeedUrl(''); setNewFeedUrl("");
loadData(); loadData();
} else { } else {
setError(data.error || 'フィード追加に失敗しました'); setError(data.error || "フィード追加に失敗しました");
} }
} catch (err) { } catch (err) {
setError('フィード追加に失敗しました'); setError("フィード追加に失敗しました");
console.error('Error adding feed:', err); console.error("Error adding feed:", err);
} }
}; };
const deleteFeed = async (feedId: string) => { const deleteFeed = async (feedId: string) => {
if (!confirm('本当にこのフィードを削除しますか?関連するすべての記事とエピソードも削除されます。')) { if (
!confirm(
"本当にこのフィードを削除しますか?関連するすべての記事とエピソードも削除されます。",
)
) {
return; return;
} }
try { try {
const res = await fetch(`/api/admin/feeds/${feedId}`, { const res = await fetch(`/api/admin/feeds/${feedId}`, {
method: 'DELETE' method: "DELETE",
}); });
const data = await res.json(); const data = await res.json();
@ -138,20 +148,20 @@ function App() {
setSuccess(data.message); setSuccess(data.message);
loadData(); loadData();
} else { } else {
setError(data.error || 'フィード削除に失敗しました'); setError(data.error || "フィード削除に失敗しました");
} }
} catch (err) { } catch (err) {
setError('フィード削除に失敗しました'); setError("フィード削除に失敗しました");
console.error('Error deleting feed:', err); console.error("Error deleting feed:", err);
} }
}; };
const toggleFeed = async (feedId: string, active: boolean) => { const toggleFeed = async (feedId: string, active: boolean) => {
try { try {
const res = await fetch(`/api/admin/feeds/${feedId}/toggle`, { const res = await fetch(`/api/admin/feeds/${feedId}/toggle`, {
method: 'PATCH', method: "PATCH",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active }) body: JSON.stringify({ active }),
}); });
const data = await res.json(); const data = await res.json();
@ -160,18 +170,18 @@ function App() {
setSuccess(data.message); setSuccess(data.message);
loadData(); loadData();
} else { } else {
setError(data.error || 'フィードステータス変更に失敗しました'); setError(data.error || "フィードステータス変更に失敗しました");
} }
} catch (err) { } catch (err) {
setError('フィードステータス変更に失敗しました'); setError("フィードステータス変更に失敗しました");
console.error('Error toggling feed:', err); console.error("Error toggling feed:", err);
} }
}; };
const triggerBatch = async () => { const triggerBatch = async () => {
try { try {
const res = await fetch('/api/admin/batch/trigger', { const res = await fetch("/api/admin/batch/trigger", {
method: 'POST' method: "POST",
}); });
const data = await res.json(); const data = await res.json();
@ -180,47 +190,54 @@ function App() {
setSuccess(data.message); setSuccess(data.message);
loadData(); // Refresh data to update batch status loadData(); // Refresh data to update batch status
} else { } else {
setError(data.error || 'バッチ処理開始に失敗しました'); setError(data.error || "バッチ処理開始に失敗しました");
} }
} catch (err) { } catch (err) {
setError('バッチ処理開始に失敗しました'); setError("バッチ処理開始に失敗しました");
console.error('Error triggering batch:', err); console.error("Error triggering batch:", err);
} }
}; };
const forceStopBatch = async () => { const forceStopBatch = async () => {
if (!confirm('実行中のバッチ処理を強制停止しますか?\n\n進行中の処理が中断され、データの整合性に影響が出る可能性があります。')) { if (
!confirm(
"実行中のバッチ処理を強制停止しますか?\n\n進行中の処理が中断され、データの整合性に影響が出る可能性があります。",
)
) {
return; return;
} }
try { try {
const res = await fetch('/api/admin/batch/force-stop', { const res = await fetch("/api/admin/batch/force-stop", {
method: 'POST' method: "POST",
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
if (data.result === 'STOPPED') { if (data.result === "STOPPED") {
setSuccess(data.message); setSuccess(data.message);
} else if (data.result === 'NO_PROCESS') { } else if (data.result === "NO_PROCESS") {
setSuccess(data.message); setSuccess(data.message);
} }
loadData(); // Refresh data to update batch status loadData(); // Refresh data to update batch status
} else { } else {
setError(data.error || 'バッチ処理強制停止に失敗しました'); setError(data.error || "バッチ処理強制停止に失敗しました");
} }
} catch (err) { } catch (err) {
setError('バッチ処理強制停止に失敗しました'); setError("バッチ処理強制停止に失敗しました");
console.error('Error force stopping batch:', err); console.error("Error force stopping batch:", err);
} }
}; };
const toggleBatchScheduler = async (enable: boolean) => { const toggleBatchScheduler = async (enable: boolean) => {
try { try {
const res = await fetch(`/api/admin/batch/${enable ? 'enable' : 'disable'}`, { const res = await fetch(
method: 'POST' `/api/admin/batch/${enable ? "enable" : "disable"}`,
}); {
method: "POST",
},
);
const data = await res.json(); const data = await res.json();
@ -228,61 +245,61 @@ function App() {
setSuccess(data.message); setSuccess(data.message);
loadData(); // Refresh data to update batch status loadData(); // Refresh data to update batch status
} else { } else {
setError(data.error || 'バッチスケジューラーの状態変更に失敗しました'); setError(data.error || "バッチスケジューラーの状態変更に失敗しました");
} }
} catch (err) { } catch (err) {
setError('バッチスケジューラーの状態変更に失敗しました'); setError("バッチスケジューラーの状態変更に失敗しました");
console.error('Error toggling batch scheduler:', err); console.error("Error toggling batch scheduler:", err);
} }
}; };
const approveFeedRequest = async (requestId: string, notes: string) => { const approveFeedRequest = async (requestId: string, notes: string) => {
try { try {
const res = await fetch(`/api/admin/feed-requests/${requestId}/approve`, { const res = await fetch(`/api/admin/feed-requests/${requestId}/approve`, {
method: 'PATCH', method: "PATCH",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ adminNotes: notes }) body: JSON.stringify({ adminNotes: notes }),
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
setSuccess(data.message || 'フィードリクエストを承認しました'); setSuccess(data.message || "フィードリクエストを承認しました");
setApprovalNotes({ ...approvalNotes, [requestId]: '' }); setApprovalNotes({ ...approvalNotes, [requestId]: "" });
loadData(); loadData();
} else { } else {
setError(data.error || 'フィードリクエストの承認に失敗しました'); setError(data.error || "フィードリクエストの承認に失敗しました");
} }
} catch (err) { } catch (err) {
setError('フィードリクエストの承認に失敗しました'); setError("フィードリクエストの承認に失敗しました");
console.error('Error approving feed request:', err); console.error("Error approving feed request:", err);
} }
}; };
const rejectFeedRequest = async (requestId: string, notes: string) => { const rejectFeedRequest = async (requestId: string, notes: string) => {
if (!confirm('このフィードリクエストを拒否しますか?')) { if (!confirm("このフィードリクエストを拒否しますか?")) {
return; return;
} }
try { try {
const res = await fetch(`/api/admin/feed-requests/${requestId}/reject`, { const res = await fetch(`/api/admin/feed-requests/${requestId}/reject`, {
method: 'PATCH', method: "PATCH",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ adminNotes: notes }) body: JSON.stringify({ adminNotes: notes }),
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
setSuccess(data.message || 'フィードリクエストを拒否しました'); setSuccess(data.message || "フィードリクエストを拒否しました");
setApprovalNotes({ ...approvalNotes, [requestId]: '' }); setApprovalNotes({ ...approvalNotes, [requestId]: "" });
loadData(); loadData();
} else { } else {
setError(data.error || 'フィードリクエストの拒否に失敗しました'); setError(data.error || "フィードリクエストの拒否に失敗しました");
} }
} catch (err) { } catch (err) {
setError('フィードリクエストの拒否に失敗しました'); setError("フィードリクエストの拒否に失敗しました");
console.error('Error rejecting feed request:', err); console.error("Error rejecting feed request:", err);
} }
}; };
@ -290,20 +307,26 @@ function App() {
setApprovalNotes({ ...approvalNotes, [requestId]: notes }); setApprovalNotes({ ...approvalNotes, [requestId]: notes });
}; };
const filteredRequests = feedRequests.filter(request => { const filteredRequests = feedRequests.filter((request) => {
if (requestFilter === 'all') return true; if (requestFilter === "all") return true;
return request.status === requestFilter; return request.status === requestFilter;
}); });
if (loading) { if (loading) {
return <div className="container"><div className="loading">...</div></div>; return (
<div className="container">
<div className="loading">...</div>
</div>
);
} }
return ( return (
<div className="container"> <div className="container">
<div className="header"> <div className="header">
<h1></h1> <h1></h1>
<p className="subtitle">RSS Podcast Manager - </p> <p className="subtitle">
RSS Podcast Manager -
</p>
</div> </div>
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
@ -311,34 +334,34 @@ function App() {
<div className="card"> <div className="card">
<div className="card-header"> <div className="card-header">
<nav style={{ display: 'flex', gap: '16px' }}> <nav style={{ display: "flex", gap: "16px" }}>
<button <button
className={`btn ${activeTab === 'dashboard' ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${activeTab === "dashboard" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab('dashboard')} onClick={() => setActiveTab("dashboard")}
> >
</button> </button>
<button <button
className={`btn ${activeTab === 'feeds' ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${activeTab === "feeds" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab('feeds')} onClick={() => setActiveTab("feeds")}
> >
</button> </button>
<button <button
className={`btn ${activeTab === 'batch' ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${activeTab === "batch" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab('batch')} onClick={() => setActiveTab("batch")}
> >
</button> </button>
<button <button
className={`btn ${activeTab === 'requests' ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${activeTab === "requests" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab('requests')} onClick={() => setActiveTab("requests")}
> >
</button> </button>
<button <button
className={`btn ${activeTab === 'env' ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${activeTab === "env" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab('env')} onClick={() => setActiveTab("env")}
> >
</button> </button>
@ -346,7 +369,7 @@ function App() {
</div> </div>
<div className="card-content"> <div className="card-content">
{activeTab === 'dashboard' && ( {activeTab === "dashboard" && (
<> <>
<div className="stats-grid"> <div className="stats-grid">
<div className="stat-card"> <div className="stat-card">
@ -370,44 +393,62 @@ function App() {
<div className="label"></div> <div className="label"></div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="value" style={{ color: stats?.batchScheduler?.enabled ? '#28a745' : '#dc3545' }}> <div
{stats?.batchScheduler?.enabled ? 'ON' : 'OFF'} className="value"
style={{
color: stats?.batchScheduler?.enabled
? "#28a745"
: "#dc3545",
}}
>
{stats?.batchScheduler?.enabled ? "ON" : "OFF"}
</div> </div>
<div className="label"></div> <div className="label"></div>
</div> </div>
</div> </div>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: "20px" }}>
<button <button
className="btn btn-success" className="btn btn-success"
onClick={triggerBatch} onClick={triggerBatch}
disabled={stats?.batchScheduler?.isRunning} disabled={stats?.batchScheduler?.isRunning}
> >
{stats?.batchScheduler?.isRunning ? 'バッチ処理実行中...' : 'バッチ処理を手動実行'} {stats?.batchScheduler?.isRunning
? "バッチ処理実行中..."
: "バッチ処理を手動実行"}
</button> </button>
{stats?.batchScheduler?.canForceStop && ( {stats?.batchScheduler?.canForceStop && (
<button <button
className="btn btn-danger" className="btn btn-danger"
onClick={forceStopBatch} onClick={forceStopBatch}
style={{ marginLeft: '8px' }} style={{ marginLeft: "8px" }}
> >
</button> </button>
)} )}
<button className="btn btn-primary" onClick={loadData} style={{ marginLeft: '8px' }}> <button
className="btn btn-primary"
onClick={loadData}
style={{ marginLeft: "8px" }}
>
</button> </button>
</div> </div>
<div style={{ fontSize: '14px', color: '#7f8c8d' }}> <div style={{ fontSize: "14px", color: "#7f8c8d" }}>
<p>: {stats?.adminPort}</p> <p>: {stats?.adminPort}</p>
<p>: {stats?.authEnabled ? '有効' : '無効'}</p> <p>: {stats?.authEnabled ? "有効" : "無効"}</p>
<p>: {stats?.lastUpdated ? new Date(stats.lastUpdated).toLocaleString('ja-JP') : '不明'}</p> <p>
:{" "}
{stats?.lastUpdated
? new Date(stats.lastUpdated).toLocaleString("ja-JP")
: "不明"}
</p>
</div> </div>
</> </>
)} )}
{activeTab === 'feeds' && ( {activeTab === "feeds" && (
<> <>
<form onSubmit={addFeed} className="add-feed-form"> <form onSubmit={addFeed} className="add-feed-form">
<div className="form-group"> <div className="form-group">
@ -427,10 +468,16 @@ function App() {
</button> </button>
</form> </form>
<div style={{ marginTop: '24px' }}> <div style={{ marginTop: "24px" }}>
<h3> ({feeds.length})</h3> <h3> ({feeds.length})</h3>
{feeds.length === 0 ? ( {feeds.length === 0 ? (
<p style={{ color: '#7f8c8d', textAlign: 'center', padding: '20px' }}> <p
style={{
color: "#7f8c8d",
textAlign: "center",
padding: "20px",
}}
>
</p> </p>
) : ( ) : (
@ -438,18 +485,20 @@ function App() {
{feeds.map((feed) => ( {feeds.map((feed) => (
<li key={feed.id} className="feed-item"> <li key={feed.id} className="feed-item">
<div className="feed-info"> <div className="feed-info">
<h3>{feed.title || 'タイトル未設定'}</h3> <h3>{feed.title || "タイトル未設定"}</h3>
<div className="url">{feed.url}</div> <div className="url">{feed.url}</div>
<span className={`status ${feed.active ? 'active' : 'inactive'}`}> <span
{feed.active ? 'アクティブ' : '非アクティブ'} className={`status ${feed.active ? "active" : "inactive"}`}
>
{feed.active ? "アクティブ" : "非アクティブ"}
</span> </span>
</div> </div>
<div className="feed-actions"> <div className="feed-actions">
<button <button
className={`btn ${feed.active ? 'btn-warning' : 'btn-success'}`} className={`btn ${feed.active ? "btn-warning" : "btn-success"}`}
onClick={() => toggleFeed(feed.id, !feed.active)} onClick={() => toggleFeed(feed.id, !feed.active)}
> >
{feed.active ? '無効化' : '有効化'} {feed.active ? "無効化" : "有効化"}
</button> </button>
<button <button
className="btn btn-danger" className="btn btn-danger"
@ -466,47 +515,67 @@ function App() {
</> </>
)} )}
{activeTab === 'batch' && ( {activeTab === "batch" && (
<> <>
<h3></h3> <h3></h3>
<p style={{ marginBottom: '20px', color: '#7f8c8d' }}> <p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
</p> </p>
<div className="stats-grid" style={{ marginBottom: '24px' }}> <div className="stats-grid" style={{ marginBottom: "24px" }}>
<div className="stat-card"> <div className="stat-card">
<div className="value" style={{ color: stats?.batchScheduler?.enabled ? '#28a745' : '#dc3545' }}> <div
{stats?.batchScheduler?.enabled ? '有効' : '無効'} className="value"
style={{
color: stats?.batchScheduler?.enabled
? "#28a745"
: "#dc3545",
}}
>
{stats?.batchScheduler?.enabled ? "有効" : "無効"}
</div> </div>
<div className="label"></div> <div className="label"></div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="value" style={{ color: stats?.batchScheduler?.isRunning ? '#ffc107' : '#6c757d' }}> <div
{stats?.batchScheduler?.isRunning ? '実行中' : '待機中'} className="value"
style={{
color: stats?.batchScheduler?.isRunning
? "#ffc107"
: "#6c757d",
}}
>
{stats?.batchScheduler?.isRunning ? "実行中" : "待機中"}
</div> </div>
<div className="label"></div> <div className="label"></div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="value" style={{ fontSize: '12px' }}> <div className="value" style={{ fontSize: "12px" }}>
{stats?.batchScheduler?.lastRun {stats?.batchScheduler?.lastRun
? new Date(stats.batchScheduler.lastRun).toLocaleString('ja-JP') ? new Date(stats.batchScheduler.lastRun).toLocaleString(
: '未実行'} "ja-JP",
)
: "未実行"}
</div> </div>
<div className="label"></div> <div className="label"></div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="value" style={{ fontSize: '12px' }}> <div className="value" style={{ fontSize: "12px" }}>
{stats?.batchScheduler?.nextRun {stats?.batchScheduler?.nextRun
? new Date(stats.batchScheduler.nextRun).toLocaleString('ja-JP') ? new Date(stats.batchScheduler.nextRun).toLocaleString(
: '未予定'} "ja-JP",
)
: "未予定"}
</div> </div>
<div className="label"></div> <div className="label"></div>
</div> </div>
</div> </div>
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: "24px" }}>
<h4></h4> <h4></h4>
<div style={{ display: 'flex', gap: '12px', marginTop: '12px' }}> <div
style={{ display: "flex", gap: "12px", marginTop: "12px" }}
>
<button <button
className="btn btn-success" className="btn btn-success"
onClick={() => toggleBatchScheduler(true)} onClick={() => toggleBatchScheduler(true)}
@ -522,147 +591,248 @@ function App() {
</button> </button>
</div> </div>
<p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}> <p
style={{
fontSize: "14px",
color: "#6c757d",
marginTop: "8px",
}}
>
</p> </p>
</div> </div>
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: "24px" }}>
<h4></h4> <h4></h4>
<div style={{ display: 'flex', gap: '12px', marginTop: '12px' }}> <div
style={{ display: "flex", gap: "12px", marginTop: "12px" }}
>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={triggerBatch} onClick={triggerBatch}
disabled={stats?.batchScheduler?.isRunning} disabled={stats?.batchScheduler?.isRunning}
> >
{stats?.batchScheduler?.isRunning ? 'バッチ処理実行中...' : 'バッチ処理を手動実行'} {stats?.batchScheduler?.isRunning
? "バッチ処理実行中..."
: "バッチ処理を手動実行"}
</button> </button>
{stats?.batchScheduler?.canForceStop && ( {stats?.batchScheduler?.canForceStop && (
<button <button className="btn btn-danger" onClick={forceStopBatch}>
className="btn btn-danger"
onClick={forceStopBatch}
>
</button> </button>
)} )}
</div> </div>
<p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}> <p
style={{
fontSize: "14px",
color: "#6c757d",
marginTop: "8px",
}}
>
</p> </p>
</div> </div>
<div style={{ padding: '16px', background: '#f8f9fa', borderRadius: '4px' }}> <div
style={{
padding: "16px",
background: "#f8f9fa",
borderRadius: "4px",
}}
>
<h4></h4> <h4></h4>
<ul style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px', paddingLeft: '20px' }}> <ul
style={{
fontSize: "14px",
color: "#6c757d",
marginTop: "8px",
paddingLeft: "20px",
}}
>
<li>6</li> <li>6</li>
<li>RSS記事の取得</li> <li>RSS記事の取得</li>
<li></li> <li>
<li></li>
<li><strong>:</strong> </li> </li>
<li>
</li>
<li>
<strong>:</strong>{" "}
</li>
</ul> </ul>
</div> </div>
</> </>
)} )}
{activeTab === 'requests' && ( {activeTab === "requests" && (
<> <>
<h3></h3> <h3></h3>
<p style={{ marginBottom: '20px', color: '#7f8c8d' }}> <p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
</p> </p>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: "20px" }}>
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: "flex", gap: "8px" }}>
<button <button
className={`btn ${requestFilter === 'all' ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${requestFilter === "all" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setRequestFilter('all')} onClick={() => setRequestFilter("all")}
> >
({feedRequests.length}) ({feedRequests.length})
</button> </button>
<button <button
className={`btn ${requestFilter === 'pending' ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${requestFilter === "pending" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setRequestFilter('pending')} onClick={() => setRequestFilter("pending")}
> >
({feedRequests.filter(r => r.status === 'pending').length}) (
{feedRequests.filter((r) => r.status === "pending").length})
</button> </button>
<button <button
className={`btn ${requestFilter === 'approved' ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${requestFilter === "approved" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setRequestFilter('approved')} onClick={() => setRequestFilter("approved")}
> >
({feedRequests.filter(r => r.status === 'approved').length}) (
{feedRequests.filter((r) => r.status === "approved").length}
)
</button> </button>
<button <button
className={`btn ${requestFilter === 'rejected' ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${requestFilter === "rejected" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setRequestFilter('rejected')} onClick={() => setRequestFilter("rejected")}
> >
({feedRequests.filter(r => r.status === 'rejected').length}) (
{feedRequests.filter((r) => r.status === "rejected").length}
)
</button> </button>
</div> </div>
</div> </div>
{filteredRequests.length === 0 ? ( {filteredRequests.length === 0 ? (
<p style={{ color: '#7f8c8d', textAlign: 'center', padding: '20px' }}> <p
{requestFilter === 'all' ? 'フィードリクエストがありません' : `${requestFilter === 'pending' ? '保留中' : requestFilter === 'approved' ? '承認済み' : '拒否済み'}のリクエストがありません`} style={{
color: "#7f8c8d",
textAlign: "center",
padding: "20px",
}}
>
{requestFilter === "all"
? "フィードリクエストがありません"
: `${requestFilter === "pending" ? "保留中" : requestFilter === "approved" ? "承認済み" : "拒否済み"}のリクエストがありません`}
</p> </p>
) : ( ) : (
<div className="requests-list"> <div className="requests-list">
{filteredRequests.map((request) => ( {filteredRequests.map((request) => (
<div key={request.id} className="feed-item" style={{ marginBottom: '16px' }}> <div
key={request.id}
className="feed-item"
style={{ marginBottom: "16px" }}
>
<div className="feed-info"> <div className="feed-info">
<h4 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>{request.url}</h4> <h4 style={{ margin: "0 0 8px 0", fontSize: "16px" }}>
{request.url}
</h4>
{request.requestMessage && ( {request.requestMessage && (
<p style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#666' }}> <p
style={{
margin: "0 0 8px 0",
fontSize: "14px",
color: "#666",
}}
>
: {request.requestMessage} : {request.requestMessage}
</p> </p>
)} )}
<div style={{ fontSize: '12px', color: '#999' }}> <div style={{ fontSize: "12px", color: "#999" }}>
<span>: {request.requestedBy || '匿名'}</span> <span>: {request.requestedBy || "匿名"}</span>
<span style={{ margin: '0 8px' }}>|</span> <span style={{ margin: "0 8px" }}>|</span>
<span>: {new Date(request.createdAt).toLocaleString('ja-JP')}</span> <span>
:{" "}
{new Date(request.createdAt).toLocaleString(
"ja-JP",
)}
</span>
{request.reviewedAt && ( {request.reviewedAt && (
<> <>
<span style={{ margin: '0 8px' }}>|</span> <span style={{ margin: "0 8px" }}>|</span>
<span>: {new Date(request.reviewedAt).toLocaleString('ja-JP')}</span> <span>
:{" "}
{new Date(request.reviewedAt).toLocaleString(
"ja-JP",
)}
</span>
</> </>
)} )}
</div> </div>
<span className={`status ${request.status === 'approved' ? 'active' : request.status === 'rejected' ? 'inactive' : ''}`}> <span
{request.status === 'pending' ? '保留中' : request.status === 'approved' ? '承認済み' : '拒否済み'} className={`status ${request.status === "approved" ? "active" : request.status === "rejected" ? "inactive" : ""}`}
>
{request.status === "pending"
? "保留中"
: request.status === "approved"
? "承認済み"
: "拒否済み"}
</span> </span>
{request.adminNotes && ( {request.adminNotes && (
<div style={{ marginTop: '8px', padding: '8px', background: '#f8f9fa', borderRadius: '4px', fontSize: '14px' }}> <div
style={{
marginTop: "8px",
padding: "8px",
background: "#f8f9fa",
borderRadius: "4px",
fontSize: "14px",
}}
>
<strong>:</strong> {request.adminNotes} <strong>:</strong> {request.adminNotes}
</div> </div>
)} )}
</div> </div>
{request.status === 'pending' && ( {request.status === "pending" && (
<div className="feed-actions" style={{ flexDirection: 'column', gap: '8px', minWidth: '200px' }}> <div
className="feed-actions"
style={{
flexDirection: "column",
gap: "8px",
minWidth: "200px",
}}
>
<textarea <textarea
placeholder="管理者メモ(任意)" placeholder="管理者メモ(任意)"
value={approvalNotes[request.id] || ''} value={approvalNotes[request.id] || ""}
onChange={(e) => updateApprovalNotes(request.id, e.target.value)} onChange={(e) =>
updateApprovalNotes(request.id, e.target.value)
}
style={{ style={{
width: '100%', width: "100%",
minHeight: '60px', minHeight: "60px",
padding: '8px', padding: "8px",
border: '1px solid #ddd', border: "1px solid #ddd",
borderRadius: '4px', borderRadius: "4px",
fontSize: '12px', fontSize: "12px",
resize: 'vertical' resize: "vertical",
}} }}
/> />
<div style={{ display: 'flex', gap: '4px' }}> <div style={{ display: "flex", gap: "4px" }}>
<button <button
className="btn btn-success" className="btn btn-success"
onClick={() => approveFeedRequest(request.id, approvalNotes[request.id] || '')} onClick={() =>
style={{ fontSize: '12px', padding: '6px 12px' }} approveFeedRequest(
request.id,
approvalNotes[request.id] || "",
)
}
style={{ fontSize: "12px", padding: "6px 12px" }}
> >
</button> </button>
<button <button
className="btn btn-danger" className="btn btn-danger"
onClick={() => rejectFeedRequest(request.id, approvalNotes[request.id] || '')} onClick={() =>
style={{ fontSize: '12px', padding: '6px 12px' }} rejectFeedRequest(
request.id,
approvalNotes[request.id] || "",
)
}
style={{ fontSize: "12px", padding: "6px 12px" }}
> >
</button> </button>
@ -676,10 +846,10 @@ function App() {
</> </>
)} )}
{activeTab === 'env' && ( {activeTab === "env" && (
<> <>
<h3></h3> <h3></h3>
<p style={{ marginBottom: '20px', color: '#7f8c8d' }}> <p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
***SET*** ***SET***
</p> </p>
@ -688,15 +858,28 @@ function App() {
<li key={key} className="env-item"> <li key={key} className="env-item">
<div className="env-key">{key}</div> <div className="env-key">{key}</div>
<div className="env-value"> <div className="env-value">
{value === undefined ? '未設定' : value} {value === undefined ? "未設定" : value}
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
<div style={{ marginTop: '20px', padding: '16px', background: '#f8f9fa', borderRadius: '4px' }}> <div
style={{
marginTop: "20px",
padding: "16px",
background: "#f8f9fa",
borderRadius: "4px",
}}
>
<h4></h4> <h4></h4>
<p style={{ fontSize: '14px', color: '#6c757d', marginTop: '8px' }}> <p
style={{
fontSize: "14px",
color: "#6c757d",
marginTop: "8px",
}}
>
.envファイルを編集するか .envファイルを編集するか
</p> </p>

View File

@ -5,8 +5,8 @@
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;

View File

@ -1,10 +1,10 @@
import React from 'react' import React from "react";
import ReactDOM from 'react-dom/client' import ReactDOM from "react-dom/client";
import App from './App.tsx' import App from "./App.tsx";
import './index.css' import "./index.css";
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>,
) );

View File

@ -1,9 +1,9 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: { build: {
outDir: 'dist', outDir: "dist",
}, },
}) });

View File

@ -31,10 +31,13 @@ const app = new Hono();
// Basic Authentication middleware (if credentials are provided) // Basic Authentication middleware (if credentials are provided)
if (config.admin.username && config.admin.password) { if (config.admin.username && config.admin.password) {
app.use("*", basicAuth({ app.use(
"*",
basicAuth({
username: config.admin.username, username: config.admin.username,
password: config.admin.password, password: config.admin.password,
})); }),
);
console.log("🔐 Admin panel authentication enabled"); console.log("🔐 Admin panel authentication enabled");
} else { } else {
console.log("⚠️ Admin panel running without authentication"); console.log("⚠️ Admin panel running without authentication");
@ -45,7 +48,9 @@ app.get("/api/admin/env", async (c) => {
try { try {
const envVars = { const envVars = {
// OpenAI Configuration // OpenAI Configuration
OPENAI_API_KEY: import.meta.env["OPENAI_API_KEY"] ? "***SET***" : undefined, OPENAI_API_KEY: import.meta.env["OPENAI_API_KEY"]
? "***SET***"
: undefined,
OPENAI_API_ENDPOINT: import.meta.env["OPENAI_API_ENDPOINT"], OPENAI_API_ENDPOINT: import.meta.env["OPENAI_API_ENDPOINT"],
OPENAI_MODEL_NAME: import.meta.env["OPENAI_MODEL_NAME"], OPENAI_MODEL_NAME: import.meta.env["OPENAI_MODEL_NAME"],
@ -65,8 +70,12 @@ app.get("/api/admin/env", async (c) => {
// Admin Configuration // Admin Configuration
ADMIN_PORT: import.meta.env["ADMIN_PORT"], ADMIN_PORT: import.meta.env["ADMIN_PORT"],
ADMIN_USERNAME: import.meta.env["ADMIN_USERNAME"] ? "***SET***" : undefined, ADMIN_USERNAME: import.meta.env["ADMIN_USERNAME"]
ADMIN_PASSWORD: import.meta.env["ADMIN_PASSWORD"] ? "***SET***" : undefined, ? "***SET***"
: undefined,
ADMIN_PASSWORD: import.meta.env["ADMIN_PASSWORD"]
? "***SET***"
: undefined,
// File Configuration // File Configuration
FEED_URLS_FILE: import.meta.env["FEED_URLS_FILE"], FEED_URLS_FILE: import.meta.env["FEED_URLS_FILE"],
@ -94,7 +103,11 @@ app.post("/api/admin/feeds", async (c) => {
try { try {
const { feedUrl } = await c.req.json<{ feedUrl: string }>(); const { feedUrl } = await c.req.json<{ feedUrl: string }>();
if (!feedUrl || typeof feedUrl !== "string" || !feedUrl.startsWith('http')) { if (
!feedUrl ||
typeof feedUrl !== "string" ||
!feedUrl.startsWith("http")
) {
return c.json({ error: "Valid feed URL is required" }, 400); return c.json({ error: "Valid feed URL is required" }, 400);
} }
@ -106,7 +119,7 @@ app.post("/api/admin/feeds", async (c) => {
return c.json({ return c.json({
result: "EXISTS", result: "EXISTS",
message: "Feed URL already exists", message: "Feed URL already exists",
feed: existingFeed feed: existingFeed,
}); });
} }
@ -116,7 +129,7 @@ app.post("/api/admin/feeds", async (c) => {
return c.json({ return c.json({
result: "CREATED", result: "CREATED",
message: "Feed URL added successfully", message: "Feed URL added successfully",
feedUrl feedUrl,
}); });
} catch (error) { } catch (error) {
console.error("Error adding feed:", error); console.error("Error adding feed:", error);
@ -140,7 +153,7 @@ app.delete("/api/admin/feeds/:id", async (c) => {
return c.json({ return c.json({
result: "DELETED", result: "DELETED",
message: "Feed deleted successfully", message: "Feed deleted successfully",
feedId feedId,
}); });
} else { } else {
return c.json({ error: "Feed not found" }, 404); return c.json({ error: "Feed not found" }, 404);
@ -164,7 +177,9 @@ app.patch("/api/admin/feeds/:id/toggle", async (c) => {
return c.json({ error: "Active status must be a boolean" }, 400); return c.json({ error: "Active status must be a boolean" }, 400);
} }
console.log(`🔄 Admin toggling feed ${feedId} to ${active ? "active" : "inactive"}`); console.log(
`🔄 Admin toggling feed ${feedId} to ${active ? "active" : "inactive"}`,
);
const updated = await toggleFeedActive(feedId, active); const updated = await toggleFeedActive(feedId, active);
@ -173,7 +188,7 @@ app.patch("/api/admin/feeds/:id/toggle", async (c) => {
result: "UPDATED", result: "UPDATED",
message: `Feed ${active ? "activated" : "deactivated"} successfully`, message: `Feed ${active ? "activated" : "deactivated"} successfully`,
feedId, feedId,
active active,
}); });
} else { } else {
return c.json({ error: "Feed not found" }, 404); return c.json({ error: "Feed not found" }, 404);
@ -211,60 +226,83 @@ app.get("/api/admin/db-diagnostic", async (c) => {
const db = new Database(config.paths.dbPath); const db = new Database(config.paths.dbPath);
// 1. Check episodes table // 1. Check episodes table
const episodeCount = db.prepare("SELECT COUNT(*) as count FROM episodes").get() as any; const episodeCount = db
.prepare("SELECT COUNT(*) as count FROM episodes")
.get() as any;
// 2. Check articles table // 2. Check articles table
const articleCount = db.prepare("SELECT COUNT(*) as count FROM articles").get() as any; const articleCount = db
.prepare("SELECT COUNT(*) as count FROM articles")
.get() as any;
// 3. Check feeds table // 3. Check feeds table
const feedCount = db.prepare("SELECT COUNT(*) as count FROM feeds").get() as any; const feedCount = db
const activeFeedCount = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1").get() as any; .prepare("SELECT COUNT(*) as count FROM feeds")
const inactiveFeedCount = db.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 0 OR active IS NULL").get() as any; .get() as any;
const activeFeedCount = db
.prepare("SELECT COUNT(*) as count FROM feeds WHERE active = 1")
.get() as any;
const inactiveFeedCount = db
.prepare(
"SELECT COUNT(*) as count FROM feeds WHERE active = 0 OR active IS NULL",
)
.get() as any;
// 4. Check orphaned episodes // 4. Check orphaned episodes
const orphanedEpisodes = db.prepare(` const orphanedEpisodes = db
.prepare(`
SELECT e.id, e.title, e.article_id SELECT e.id, e.title, e.article_id
FROM episodes e FROM episodes e
LEFT JOIN articles a ON e.article_id = a.id LEFT JOIN articles a ON e.article_id = a.id
WHERE a.id IS NULL WHERE a.id IS NULL
`).all() as any[]; `)
.all() as any[];
// 5. Check orphaned articles // 5. Check orphaned articles
const orphanedArticles = db.prepare(` const orphanedArticles = db
.prepare(`
SELECT a.id, a.title, a.feed_id SELECT a.id, a.title, a.feed_id
FROM articles a FROM articles a
LEFT JOIN feeds f ON a.feed_id = f.id LEFT JOIN feeds f ON a.feed_id = f.id
WHERE f.id IS NULL WHERE f.id IS NULL
`).all() as any[]; `)
.all() as any[];
// 6. Check episodes with articles but feeds are inactive // 6. Check episodes with articles but feeds are inactive
const episodesInactiveFeeds = db.prepare(` const episodesInactiveFeeds = db
.prepare(`
SELECT e.id, e.title, f.active, f.title as feed_title SELECT e.id, e.title, f.active, f.title as feed_title
FROM episodes e FROM episodes e
JOIN articles a ON e.article_id = a.id JOIN articles a ON e.article_id = a.id
JOIN feeds f ON a.feed_id = f.id JOIN feeds f ON a.feed_id = f.id
WHERE f.active = 0 OR f.active IS NULL WHERE f.active = 0 OR f.active IS NULL
`).all() as any[]; `)
.all() as any[];
// 7. Test the JOIN query // 7. Test the JOIN query
const joinResult = db.prepare(` const joinResult = db
.prepare(`
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM episodes e FROM episodes e
JOIN articles a ON e.article_id = a.id JOIN articles a ON e.article_id = a.id
JOIN feeds f ON a.feed_id = f.id JOIN feeds f ON a.feed_id = f.id
WHERE f.active = 1 WHERE f.active = 1
`).get() as any; `)
.get() as any;
// 8. Sample feed details // 8. Sample feed details
const sampleFeeds = db.prepare(` const sampleFeeds = db
.prepare(`
SELECT id, title, url, active, created_at SELECT id, title, url, active, created_at
FROM feeds FROM feeds
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 5 LIMIT 5
`).all() as any[]; `)
.all() as any[];
// 9. Sample episode-article-feed chain // 9. Sample episode-article-feed chain
const sampleChain = db.prepare(` const sampleChain = db
.prepare(`
SELECT SELECT
e.id as episode_id, e.title as episode_title, e.id as episode_id, e.title as episode_title,
a.id as article_id, a.title as article_title, a.id as article_id, a.title as article_title,
@ -274,7 +312,8 @@ app.get("/api/admin/db-diagnostic", async (c) => {
LEFT JOIN feeds f ON a.feed_id = f.id LEFT JOIN feeds f ON a.feed_id = f.id
ORDER BY e.created_at DESC ORDER BY e.created_at DESC
LIMIT 5 LIMIT 5
`).all() as any[]; `)
.all() as any[];
db.close(); db.close();
@ -309,7 +348,10 @@ app.get("/api/admin/db-diagnostic", async (c) => {
return c.json(diagnosticResult); return c.json(diagnosticResult);
} catch (error) { } catch (error) {
console.error("Error running database diagnostic:", error); console.error("Error running database diagnostic:", error);
return c.json({ error: "Failed to run database diagnostic", details: error.message }, 500); return c.json(
{ error: "Failed to run database diagnostic", details: error.message },
500,
);
} }
}); });
@ -333,13 +375,13 @@ app.patch("/api/admin/feed-requests/:id/approve", async (c) => {
// First get the request to get the URL // First get the request to get the URL
const requests = await getFeedRequests(); const requests = await getFeedRequests();
const request = requests.find(r => r.id === requestId); const request = requests.find((r) => r.id === requestId);
if (!request) { if (!request) {
return c.json({ error: "Feed request not found" }, 404); return c.json({ error: "Feed request not found" }, 404);
} }
if (request.status !== 'pending') { if (request.status !== "pending") {
return c.json({ error: "Feed request already processed" }, 400); return c.json({ error: "Feed request already processed" }, 400);
} }
@ -349,9 +391,9 @@ app.patch("/api/admin/feed-requests/:id/approve", async (c) => {
// Update request status // Update request status
const updated = await updateFeedRequestStatus( const updated = await updateFeedRequestStatus(
requestId, requestId,
'approved', "approved",
'admin', "admin",
adminNotes adminNotes,
); );
if (!updated) { if (!updated) {
@ -360,7 +402,7 @@ app.patch("/api/admin/feed-requests/:id/approve", async (c) => {
return c.json({ return c.json({
success: true, success: true,
message: "Feed request approved and feed added successfully" message: "Feed request approved and feed added successfully",
}); });
} catch (error) { } catch (error) {
console.error("Error approving feed request:", error); console.error("Error approving feed request:", error);
@ -376,9 +418,9 @@ app.patch("/api/admin/feed-requests/:id/reject", async (c) => {
const updated = await updateFeedRequestStatus( const updated = await updateFeedRequestStatus(
requestId, requestId,
'rejected', "rejected",
'admin', "admin",
adminNotes adminNotes,
); );
if (!updated) { if (!updated) {
@ -387,7 +429,7 @@ app.patch("/api/admin/feed-requests/:id/reject", async (c) => {
return c.json({ return c.json({
success: true, success: true,
message: "Feed request rejected successfully" message: "Feed request rejected successfully",
}); });
} catch (error) { } catch (error) {
console.error("Error rejecting feed request:", error); console.error("Error rejecting feed request:", error);
@ -412,7 +454,7 @@ app.post("/api/admin/batch/enable", async (c) => {
return c.json({ return c.json({
success: true, success: true,
message: "Batch scheduler enabled successfully", message: "Batch scheduler enabled successfully",
status: batchScheduler.getStatus() status: batchScheduler.getStatus(),
}); });
} catch (error) { } catch (error) {
console.error("Error enabling batch scheduler:", error); console.error("Error enabling batch scheduler:", error);
@ -426,7 +468,7 @@ app.post("/api/admin/batch/disable", async (c) => {
return c.json({ return c.json({
success: true, success: true,
message: "Batch scheduler disabled successfully", message: "Batch scheduler disabled successfully",
status: batchScheduler.getStatus() status: batchScheduler.getStatus(),
}); });
} catch (error) { } catch (error) {
console.error("Error disabling batch scheduler:", error); console.error("Error disabling batch scheduler:", error);
@ -444,10 +486,11 @@ app.get("/api/admin/stats", async (c) => {
const stats = { const stats = {
totalFeeds: feeds.length, totalFeeds: feeds.length,
activeFeeds: feeds.filter(f => f.active).length, activeFeeds: feeds.filter((f) => f.active).length,
inactiveFeeds: feeds.filter(f => !f.active).length, inactiveFeeds: feeds.filter((f) => !f.active).length,
totalEpisodes: episodes.length, totalEpisodes: episodes.length,
pendingRequests: feedRequests.filter(r => r.status === 'pending').length, pendingRequests: feedRequests.filter((r) => r.status === "pending")
.length,
totalRequests: feedRequests.length, totalRequests: feedRequests.length,
batchScheduler: { batchScheduler: {
enabled: batchStatus.enabled, enabled: batchStatus.enabled,
@ -472,14 +515,14 @@ app.post("/api/admin/batch/trigger", async (c) => {
console.log("🚀 Manual batch process triggered via admin panel"); console.log("🚀 Manual batch process triggered via admin panel");
// Use the batch scheduler's manual trigger method // Use the batch scheduler's manual trigger method
batchScheduler.triggerManualRun().catch(error => { batchScheduler.triggerManualRun().catch((error) => {
console.error("❌ Manual admin batch process failed:", error); console.error("❌ Manual admin batch process failed:", error);
}); });
return c.json({ return c.json({
result: "TRIGGERED", result: "TRIGGERED",
message: "Batch process started in background", message: "Batch process started in background",
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
} catch (error) { } catch (error) {
console.error("Error triggering admin batch process:", error); console.error("Error triggering admin batch process:", error);
@ -497,14 +540,17 @@ app.post("/api/admin/batch/force-stop", async (c) => {
return c.json({ return c.json({
result: "STOPPED", result: "STOPPED",
message: "Batch process force stop signal sent", message: "Batch process force stop signal sent",
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
} else { } else {
return c.json({ return c.json(
{
result: "NO_PROCESS", result: "NO_PROCESS",
message: "No batch process is currently running", message: "No batch process is currently running",
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}, 200); },
200,
);
} }
} catch (error) { } catch (error) {
console.error("Error force stopping batch process:", error); console.error("Error force stopping batch process:", error);
@ -606,7 +652,9 @@ serve(
}, },
(info) => { (info) => {
console.log(`🔧 Admin panel running on http://localhost:${info.port}`); console.log(`🔧 Admin panel running on http://localhost:${info.port}`);
console.log(`📊 Admin authentication: ${config.admin.username && config.admin.password ? "enabled" : "disabled"}`); console.log(
`📊 Admin authentication: ${config.admin.username && config.admin.password ? "enabled" : "disabled"}`,
);
console.log(`🗄️ Database: ${config.paths.dbPath}`); console.log(`🗄️ Database: ${config.paths.dbPath}`);
}, },
); );

77
biome.json Normal file
View File

@ -0,0 +1,77 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"ignore": [
"node_modules",
"dist",
"build",
"data",
"public/podcast_audio",
"frontend/dist",
"admin-panel/dist"
]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80,
"attributePosition": "auto"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"recommended": true
},
"complexity": {
"recommended": true
},
"correctness": {
"recommended": true
},
"performance": {
"recommended": true
},
"security": {
"recommended": true
},
"style": {
"recommended": true
},
"suspicious": {
"recommended": true
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto"
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
}
}

View File

@ -7,6 +7,7 @@
"@aws-sdk/client-polly": "^3.823.0", "@aws-sdk/client-polly": "^3.823.0",
"@hono/node-server": "^1.14.3", "@hono/node-server": "^1.14.3",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
"cheerio": "^1.0.0",
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
"hono": "^4.7.11", "hono": "^4.7.11",
"openai": "^4.104.0", "openai": "^4.104.0",
@ -17,7 +18,9 @@
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "latest", "@types/bun": "latest",
"@types/cheerio": "^1.0.0",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"@types/react": "^19.1.6", "@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
@ -123,6 +126,24 @@
"@babel/types": ["@babel/types@7.27.3", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw=="], "@babel/types": ["@babel/types@7.27.3", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
"@derhuerst/http-basic": ["@derhuerst/http-basic@8.2.4", "", { "dependencies": { "caseless": "^0.12.0", "concat-stream": "^2.0.0", "http-response-object": "^3.0.1", "parse-cache-control": "^1.0.1" } }, "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw=="], "@derhuerst/http-basic": ["@derhuerst/http-basic@8.2.4", "", { "dependencies": { "caseless": "^0.12.0", "concat-stream": "^2.0.0", "http-response-object": "^3.0.1", "parse-cache-control": "^1.0.1" } }, "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],
@ -317,6 +338,8 @@
"@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="], "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="],
"@types/cheerio": ["@types/cheerio@1.0.0", "", { "dependencies": { "cheerio": "*" } }, "sha512-zAaImHWoh5RY2CLgU2mvg3bl2k3F65B0N5yphuII3ythFLPmJhL7sj1RDu6gSxcgqHlETbr/lhA2OBY+WF1fXQ=="],
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
"@types/node": ["@types/node@22.15.29", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ=="], "@types/node": ["@types/node@22.15.29", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ=="],
@ -339,6 +362,8 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="], "bowser": ["bowser@2.11.0", "", {}, "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="],
"browserslist": ["browserslist@4.25.0", "", { "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA=="], "browserslist": ["browserslist@4.25.0", "", { "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA=="],
@ -353,6 +378,10 @@
"caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="],
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
@ -361,16 +390,30 @@
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"css-select": ["css-select@5.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg=="],
"css-what": ["css-what@6.1.0", "", {}, "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.162", "", {}, "sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA=="], "electron-to-chromium": ["electron-to-chromium@1.5.162", "", {}, "sha512-hQA+Zb5QQwoSaXJWEAGEw1zhk//O7qDzib05Z4qTqZfNju/FAkrm5ZInp0JbTp4Z18A6bilopdZWEYrFSsfllA=="],
"encoding-sniffer": ["encoding-sniffer@0.2.0", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg=="],
"entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@ -423,12 +466,16 @@
"hono": ["hono@4.7.11", "", {}, "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ=="], "hono": ["hono@4.7.11", "", {}, "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ=="],
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
"http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "^10.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="], "http-response-object": ["http-response-object@3.0.2", "", { "dependencies": { "@types/node": "^10.0.3" } }, "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@ -455,10 +502,18 @@
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], "openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
"parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="], "parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
@ -485,6 +540,8 @@
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
@ -509,6 +566,8 @@
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
@ -523,6 +582,10 @@
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
@ -539,10 +602,16 @@
"bun-types/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="], "bun-types/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="], "http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="],
"openai/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="], "openai/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="],
"parse5/entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="],
"rss-parser/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], "rss-parser/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],

View File

@ -1,28 +1,28 @@
import js from '@eslint/js' import js from "@eslint/js";
import globals from 'globals' import globals from "globals";
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from 'typescript-eslint' import tseslint from "typescript-eslint";
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist'] }, { ignores: ["dist"] },
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'], files: ["**/*.{ts,tsx}"],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
plugins: { plugins: {
'react-hooks': reactHooks, "react-hooks": reactHooks,
'react-refresh': reactRefresh, "react-refresh": reactRefresh,
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [ "react-refresh/only-export-components": [
'warn', "warn",
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
}, },
}, },
) );

View File

@ -1,13 +1,15 @@
import { Routes, Route, Link, useLocation } from 'react-router-dom' import { Routes, Route, Link, useLocation } from "react-router-dom";
import EpisodeList from './components/EpisodeList' import EpisodeList from "./components/EpisodeList";
import FeedManager from './components/FeedManager' import FeedManager from "./components/FeedManager";
import FeedList from './components/FeedList' import FeedList from "./components/FeedList";
import FeedDetail from './components/FeedDetail' import FeedDetail from "./components/FeedDetail";
import EpisodeDetail from './components/EpisodeDetail' import EpisodeDetail from "./components/EpisodeDetail";
function App() { function App() {
const location = useLocation() const location = useLocation();
const isMainPage = ['/', '/feeds', '/feed-requests'].includes(location.pathname) const isMainPage = ["/", "/feeds", "/feed-requests"].includes(
location.pathname,
);
return ( return (
<div className="container"> <div className="container">
@ -15,25 +17,27 @@ function App() {
<> <>
<div className="header"> <div className="header">
<div className="title">Voice RSS Summary</div> <div className="title">Voice RSS Summary</div>
<div className="subtitle">RSS </div> <div className="subtitle">
RSS
</div>
</div> </div>
<div className="tabs"> <div className="tabs">
<Link <Link
to="/" to="/"
className={`tab ${location.pathname === '/' ? 'active' : ''}`} className={`tab ${location.pathname === "/" ? "active" : ""}`}
> >
</Link> </Link>
<Link <Link
to="/feeds" to="/feeds"
className={`tab ${location.pathname === '/feeds' ? 'active' : ''}`} className={`tab ${location.pathname === "/feeds" ? "active" : ""}`}
> >
</Link> </Link>
<Link <Link
to="/feed-requests" to="/feed-requests"
className={`tab ${location.pathname === '/feed-requests' ? 'active' : ''}`} className={`tab ${location.pathname === "/feed-requests" ? "active" : ""}`}
> >
</Link> </Link>
@ -51,7 +55,7 @@ function App() {
</Routes> </Routes>
</div> </div>
</div> </div>
) );
} }
export default App export default App;

View File

@ -1,58 +1,57 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from "react";
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from "react-router-dom";
interface EpisodeWithFeedInfo { interface EpisodeWithFeedInfo {
id: string id: string;
title: string title: string;
description?: string description?: string;
audioPath: string audioPath: string;
duration?: number duration?: number;
fileSize?: number fileSize?: number;
createdAt: string createdAt: string;
articleId: string articleId: string;
articleTitle: string articleTitle: string;
articleLink: string articleLink: string;
articlePubDate: string articlePubDate: string;
feedId: string feedId: string;
feedTitle?: string feedTitle?: string;
feedUrl: string feedUrl: string;
} }
function EpisodeDetail() { function EpisodeDetail() {
const { episodeId } = useParams<{ episodeId: string }>() const { episodeId } = useParams<{ episodeId: string }>();
const [episode, setEpisode] = useState<EpisodeWithFeedInfo | null>(null) const [episode, setEpisode] = useState<EpisodeWithFeedInfo | null>(null);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [useDatabase, setUseDatabase] = useState(true) const [useDatabase, setUseDatabase] = useState(true);
useEffect(() => { useEffect(() => {
fetchEpisode() fetchEpisode();
}, [episodeId, useDatabase]) }, [episodeId, useDatabase]);
const fetchEpisode = async () => { const fetchEpisode = async () => {
if (!episodeId) return if (!episodeId) return;
try { try {
setLoading(true) setLoading(true);
if (useDatabase) { if (useDatabase) {
// Try to fetch from database with source info first // Try to fetch from database with source info first
const response = await fetch(`/api/episode-with-source/${episodeId}`) const response = await fetch(`/api/episode-with-source/${episodeId}`);
if (!response.ok) { if (!response.ok) {
throw new Error('データベースからの取得に失敗しました') throw new Error("データベースからの取得に失敗しました");
} }
const data = await response.json() const data = await response.json();
setEpisode(data.episode) setEpisode(data.episode);
} else { } else {
// Fallback to XML parsing (existing functionality) // Fallback to XML parsing (existing functionality)
const response = await fetch(`/api/episode/${episodeId}`) const response = await fetch(`/api/episode/${episodeId}`);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json() const errorData = await response.json();
throw new Error(errorData.error || 'エピソードの取得に失敗しました') throw new Error(errorData.error || "エピソードの取得に失敗しました");
} }
const data = await response.json() const data = await response.json();
const xmlEpisode = data.episode const xmlEpisode = data.episode;
// Convert XML episode to EpisodeWithFeedInfo format // Convert XML episode to EpisodeWithFeedInfo format
const convertedEpisode: EpisodeWithFeedInfo = { const convertedEpisode: EpisodeWithFeedInfo = {
@ -65,122 +64,140 @@ function EpisodeDetail() {
articleTitle: xmlEpisode.title, articleTitle: xmlEpisode.title,
articleLink: xmlEpisode.link, articleLink: xmlEpisode.link,
articlePubDate: xmlEpisode.pubDate, articlePubDate: xmlEpisode.pubDate,
feedId: '', feedId: "",
feedTitle: 'RSS Feed', feedTitle: "RSS Feed",
feedUrl: '' feedUrl: "",
} };
setEpisode(convertedEpisode) setEpisode(convertedEpisode);
} }
} catch (err) { } catch (err) {
console.error('Episode fetch error:', err) console.error("Episode fetch error:", err);
if (useDatabase) { if (useDatabase) {
// Fallback to XML if database fails // Fallback to XML if database fails
console.log('Falling back to XML parsing...') console.log("Falling back to XML parsing...");
setUseDatabase(false) setUseDatabase(false);
return return;
} }
setError(err instanceof Error ? err.message : 'エラーが発生しました') setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
const shareEpisode = () => { const shareEpisode = () => {
const shareUrl = window.location.href const shareUrl = window.location.href;
navigator.clipboard.writeText(shareUrl).then(() => { navigator.clipboard
alert('エピソードリンクをクリップボードにコピーしました') .writeText(shareUrl)
}).catch(() => { .then(() => {
// Fallback for older browsers alert("エピソードリンクをクリップボードにコピーしました");
const textArea = document.createElement('textarea')
textArea.value = shareUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('エピソードリンクをクリップボードにコピーしました')
}) })
} .catch(() => {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = shareUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
alert("エピソードリンクをクリップボードにコピーしました");
});
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ja-JP') return new Date(dateString).toLocaleString("ja-JP");
} };
const formatFileSize = (bytes?: number) => { const formatFileSize = (bytes?: number) => {
if (!bytes) return '' if (!bytes) return "";
const units = ['B', 'KB', 'MB', 'GB'] const units = ["B", "KB", "MB", "GB"];
let unitIndex = 0 let unitIndex = 0;
let fileSize = bytes let fileSize = bytes;
while (fileSize >= 1024 && unitIndex < units.length - 1) { while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024 fileSize /= 1024;
unitIndex++ unitIndex++;
} }
return `${fileSize.toFixed(1)} ${units[unitIndex]}` return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
} };
if (loading) { if (loading) {
return ( return (
<div className="container"> <div className="container">
<div className="loading">...</div> <div className="loading">...</div>
</div> </div>
) );
} }
if (error) { if (error) {
return ( return (
<div className="container"> <div className="container">
<div className="error">{error}</div> <div className="error">{error}</div>
<Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}> <Link
to="/"
className="btn btn-secondary"
style={{ marginTop: "20px" }}
>
</Link> </Link>
</div> </div>
) );
} }
if (!episode) { if (!episode) {
return ( return (
<div className="container"> <div className="container">
<div className="error"></div> <div className="error"></div>
<Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}> <Link
to="/"
className="btn btn-secondary"
style={{ marginTop: "20px" }}
>
</Link> </Link>
</div> </div>
) );
} }
return ( return (
<div className="container"> <div className="container">
<div className="header"> <div className="header">
<Link to="/" className="btn btn-secondary" style={{ marginBottom: '20px' }}> <Link
to="/"
className="btn btn-secondary"
style={{ marginBottom: "20px" }}
>
</Link> </Link>
<div className="title" style={{ fontSize: '28px', marginBottom: '10px' }}> <div
className="title"
style={{ fontSize: "28px", marginBottom: "10px" }}
>
{episode.title} {episode.title}
</div> </div>
<div className="subtitle" style={{ color: '#666', marginBottom: '20px' }}> <div
className="subtitle"
style={{ color: "#666", marginBottom: "20px" }}
>
: {formatDate(episode.createdAt)} : {formatDate(episode.createdAt)}
</div> </div>
</div> </div>
<div className="content"> <div className="content">
<div style={{ marginBottom: '30px' }}> <div style={{ marginBottom: "30px" }}>
<audio <audio
controls controls
className="audio-player" className="audio-player"
src={episode.audioPath} src={episode.audioPath}
style={{ width: '100%', height: '60px' }} style={{ width: "100%", height: "60px" }}
> >
使 使
</audio> </audio>
</div> </div>
<div style={{ display: 'flex', gap: '15px', marginBottom: '30px' }}> <div style={{ display: "flex", gap: "15px", marginBottom: "30px" }}>
<button <button className="btn btn-primary" onClick={shareEpisode}>
className="btn btn-primary"
onClick={shareEpisode}
>
</button> </button>
{episode.articleLink && ( {episode.articleLink && (
@ -194,62 +211,77 @@ function EpisodeDetail() {
</a> </a>
)} )}
{episode.feedId && ( {episode.feedId && (
<Link <Link to={`/feeds/${episode.feedId}`} className="btn btn-secondary">
to={`/feeds/${episode.feedId}`}
className="btn btn-secondary"
>
</Link> </Link>
)} )}
</div> </div>
<div style={{ marginBottom: '30px' }}> <div style={{ marginBottom: "30px" }}>
<h3 style={{ marginBottom: '15px' }}></h3> <h3 style={{ marginBottom: "15px" }}></h3>
<div style={{ <div
backgroundColor: '#f8f9fa', style={{
padding: '20px', backgroundColor: "#f8f9fa",
borderRadius: '8px', padding: "20px",
fontSize: '14px' borderRadius: "8px",
}}> fontSize: "14px",
}}
>
{episode.feedTitle && ( {episode.feedTitle && (
<div style={{ marginBottom: '10px' }}> <div style={{ marginBottom: "10px" }}>
<strong>:</strong> <strong>:</strong>
<Link to={`/feeds/${episode.feedId}`} style={{ marginLeft: '5px', color: '#007bff' }}> <Link
to={`/feeds/${episode.feedId}`}
style={{ marginLeft: "5px", color: "#007bff" }}
>
{episode.feedTitle} {episode.feedTitle}
</Link> </Link>
</div> </div>
)} )}
{episode.feedUrl && ( {episode.feedUrl && (
<div style={{ marginBottom: '10px' }}> <div style={{ marginBottom: "10px" }}>
<strong>URL:</strong> <strong>URL:</strong>
<a href={episode.feedUrl} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}> <a
href={episode.feedUrl}
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: "5px" }}
>
{episode.feedUrl} {episode.feedUrl}
</a> </a>
</div> </div>
)} )}
{episode.articleTitle && episode.articleTitle !== episode.title && ( {episode.articleTitle && episode.articleTitle !== episode.title && (
<div style={{ marginBottom: '10px' }}> <div style={{ marginBottom: "10px" }}>
<strong>:</strong> {episode.articleTitle} <strong>:</strong> {episode.articleTitle}
</div> </div>
)} )}
{episode.articlePubDate && ( {episode.articlePubDate && (
<div style={{ marginBottom: '10px' }}> <div style={{ marginBottom: "10px" }}>
<strong>:</strong> {formatDate(episode.articlePubDate)} <strong>:</strong>{" "}
{formatDate(episode.articlePubDate)}
</div> </div>
)} )}
{episode.fileSize && ( {episode.fileSize && (
<div style={{ marginBottom: '10px' }}> <div style={{ marginBottom: "10px" }}>
<strong>:</strong> {formatFileSize(episode.fileSize)} <strong>:</strong>{" "}
{formatFileSize(episode.fileSize)}
</div> </div>
)} )}
{episode.duration && ( {episode.duration && (
<div style={{ marginBottom: '10px' }}> <div style={{ marginBottom: "10px" }}>
<strong>:</strong> {Math.floor(episode.duration / 60)}{episode.duration % 60} <strong>:</strong> {Math.floor(episode.duration / 60)}
{episode.duration % 60}
</div> </div>
)} )}
<div> <div>
<strong>URL:</strong> <strong>URL:</strong>
<a href={episode.audioPath} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}> <a
href={episode.audioPath}
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: "5px" }}
>
</a> </a>
</div> </div>
@ -258,21 +290,23 @@ function EpisodeDetail() {
{episode.description && ( {episode.description && (
<div> <div>
<h3 style={{ marginBottom: '15px' }}></h3> <h3 style={{ marginBottom: "15px" }}></h3>
<div style={{ <div
backgroundColor: '#fff', style={{
padding: '20px', backgroundColor: "#fff",
border: '1px solid #e9ecef', padding: "20px",
borderRadius: '8px', border: "1px solid #e9ecef",
lineHeight: '1.6' borderRadius: "8px",
}}> lineHeight: "1.6",
}}
>
{episode.description} {episode.description}
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
) );
} }
export default EpisodeDetail export default EpisodeDetail;

View File

@ -1,80 +1,81 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from "react";
import { Link } from 'react-router-dom' import { Link } from "react-router-dom";
interface Episode { interface Episode {
id: string id: string;
title: string title: string;
description: string description: string;
pubDate: string pubDate: string;
audioUrl: string audioUrl: string;
audioLength: string audioLength: string;
guid: string guid: string;
link: string link: string;
} }
interface EpisodeWithFeedInfo { interface EpisodeWithFeedInfo {
id: string id: string;
title: string title: string;
description?: string description?: string;
audioPath: string audioPath: string;
duration?: number duration?: number;
fileSize?: number fileSize?: number;
createdAt: string createdAt: string;
articleId: string articleId: string;
articleTitle: string articleTitle: string;
articleLink: string articleLink: string;
articlePubDate: string articlePubDate: string;
feedId: string feedId: string;
feedTitle?: string feedTitle?: string;
feedUrl: string feedUrl: string;
} }
function EpisodeList() { function EpisodeList() {
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]) const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<string | null>(null) const [currentAudio, setCurrentAudio] = useState<string | null>(null);
const [useDatabase, setUseDatabase] = useState(true) const [useDatabase, setUseDatabase] = useState(true);
useEffect(() => { useEffect(() => {
fetchEpisodes() fetchEpisodes();
}, [useDatabase]) }, [useDatabase]);
const fetchEpisodes = async () => { const fetchEpisodes = async () => {
try { try {
setLoading(true) setLoading(true);
setError(null) setError(null);
if (useDatabase) { if (useDatabase) {
// Try to fetch from database first // Try to fetch from database first
const response = await fetch('/api/episodes-with-feed-info') const response = await fetch("/api/episodes-with-feed-info");
if (!response.ok) { if (!response.ok) {
throw new Error('データベースからの取得に失敗しました') throw new Error("データベースからの取得に失敗しました");
} }
const data = await response.json() const data = await response.json();
const dbEpisodes = data.episodes || [] const dbEpisodes = data.episodes || [];
if (dbEpisodes.length === 0) { if (dbEpisodes.length === 0) {
// Database is empty, fallback to XML // Database is empty, fallback to XML
console.log('Database is empty, falling back to XML parsing...') console.log("Database is empty, falling back to XML parsing...");
setUseDatabase(false) setUseDatabase(false);
return return;
} }
setEpisodes(dbEpisodes) setEpisodes(dbEpisodes);
} else { } else {
// Use XML parsing as primary source // Use XML parsing as primary source
const response = await fetch('/api/episodes-from-xml') const response = await fetch("/api/episodes-from-xml");
if (!response.ok) { if (!response.ok) {
const errorData = await response.json() const errorData = await response.json();
throw new Error(errorData.error || 'エピソードの取得に失敗しました') throw new Error(errorData.error || "エピソードの取得に失敗しました");
} }
const data = await response.json() const data = await response.json();
console.log('Fetched episodes from XML:', data) console.log("Fetched episodes from XML:", data);
// Convert XML episodes to EpisodeWithFeedInfo format // Convert XML episodes to EpisodeWithFeedInfo format
const xmlEpisodes = data.episodes || [] const xmlEpisodes = data.episodes || [];
const convertedEpisodes: EpisodeWithFeedInfo[] = xmlEpisodes.map((episode: Episode) => ({ const convertedEpisodes: EpisodeWithFeedInfo[] = xmlEpisodes.map(
(episode: Episode) => ({
id: episode.id, id: episode.id,
title: episode.title, title: episode.title,
description: episode.description, description: episode.description,
@ -84,103 +85,118 @@ function EpisodeList() {
articleTitle: episode.title, articleTitle: episode.title,
articleLink: episode.link, articleLink: episode.link,
articlePubDate: episode.pubDate, articlePubDate: episode.pubDate,
feedId: '', feedId: "",
feedTitle: 'RSS Feed', feedTitle: "RSS Feed",
feedUrl: '' feedUrl: "",
})) }),
setEpisodes(convertedEpisodes) );
setEpisodes(convertedEpisodes);
} }
} catch (err) { } catch (err) {
console.error('Episode fetch error:', err) console.error("Episode fetch error:", err);
if (useDatabase) { if (useDatabase) {
// Fallback to XML if database fails // Fallback to XML if database fails
console.log('Falling back to XML parsing...') console.log("Falling back to XML parsing...");
setUseDatabase(false) setUseDatabase(false);
return return;
} }
setError(err instanceof Error ? err.message : 'エラーが発生しました') setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ja-JP') return new Date(dateString).toLocaleString("ja-JP");
} };
const playAudio = (audioPath: string) => { const playAudio = (audioPath: string) => {
if (currentAudio) { if (currentAudio) {
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement const currentPlayer = document.getElementById(
currentAudio,
) as HTMLAudioElement;
if (currentPlayer) { if (currentPlayer) {
currentPlayer.pause() currentPlayer.pause();
currentPlayer.currentTime = 0 currentPlayer.currentTime = 0;
} }
} }
setCurrentAudio(audioPath) setCurrentAudio(audioPath);
} };
const shareEpisode = (episode: EpisodeWithFeedInfo) => { const shareEpisode = (episode: EpisodeWithFeedInfo) => {
const shareUrl = `${window.location.origin}/episode/${episode.id}` const shareUrl = `${window.location.origin}/episode/${episode.id}`;
navigator.clipboard.writeText(shareUrl).then(() => { navigator.clipboard
alert('エピソードリンクをクリップボードにコピーしました') .writeText(shareUrl)
}).catch(() => { .then(() => {
// Fallback for older browsers alert("エピソードリンクをクリップボードにコピーしました");
const textArea = document.createElement('textarea')
textArea.value = shareUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('エピソードリンクをクリップボードにコピーしました')
}) })
} .catch(() => {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = shareUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
alert("エピソードリンクをクリップボードにコピーしました");
});
};
const formatFileSize = (bytes?: number) => { const formatFileSize = (bytes?: number) => {
if (!bytes) return '' if (!bytes) return "";
const units = ['B', 'KB', 'MB', 'GB'] const units = ["B", "KB", "MB", "GB"];
let unitIndex = 0 let unitIndex = 0;
let fileSize = bytes let fileSize = bytes;
while (fileSize >= 1024 && unitIndex < units.length - 1) { while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024 fileSize /= 1024;
unitIndex++ unitIndex++;
} }
return `${fileSize.toFixed(1)} ${units[unitIndex]}` return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
} };
if (loading) { if (loading) {
return <div className="loading">...</div> return <div className="loading">...</div>;
} }
if (error) { if (error) {
return <div className="error">{error}</div> return <div className="error">{error}</div>;
} }
if (episodes.length === 0) { if (episodes.length === 0) {
return ( return (
<div className="empty-state"> <div className="empty-state">
<p></p> <p></p>
<p>RSSフィードをリクエストするか</p> <p>
RSSフィードをリクエストするか
</p>
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={fetchEpisodes} onClick={fetchEpisodes}
style={{ marginTop: '10px' }} style={{ marginTop: "10px" }}
> >
</button> </button>
</div> </div>
) );
} }
return ( return (
<div> <div>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div
style={{
marginBottom: "20px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2> ({episodes.length})</h2> <h2> ({episodes.length})</h2>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}> <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
<span style={{ fontSize: '12px', color: '#666' }}> <span style={{ fontSize: "12px", color: "#666" }}>
: {useDatabase ? 'データベース' : 'XML'} : {useDatabase ? "データベース" : "XML"}
</span> </span>
<button className="btn btn-secondary" onClick={fetchEpisodes}> <button className="btn btn-secondary" onClick={fetchEpisodes}>
@ -191,33 +207,52 @@ function EpisodeList() {
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th style={{ width: '35%' }}></th> <th style={{ width: "35%" }}></th>
<th style={{ width: '25%' }}></th> <th style={{ width: "25%" }}></th>
<th style={{ width: '15%' }}></th> <th style={{ width: "15%" }}></th>
<th style={{ width: '25%' }}></th> <th style={{ width: "25%" }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{episodes.map((episode) => ( {episodes.map((episode) => (
<tr key={episode.id}> <tr key={episode.id}>
<td> <td>
<div style={{ marginBottom: '8px' }}> <div style={{ marginBottom: "8px" }}>
<strong> <strong>
<Link <Link
to={`/episode/${episode.id}`} to={`/episode/${episode.id}`}
style={{ textDecoration: 'none', color: '#007bff' }} style={{ textDecoration: "none", color: "#007bff" }}
> >
{episode.title} {episode.title}
</Link> </Link>
</strong> </strong>
</div> </div>
{episode.feedTitle && ( {episode.feedTitle && (
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}> <div
: <Link to={`/feeds/${episode.feedId}`} style={{ color: '#007bff' }}>{episode.feedTitle}</Link> style={{
fontSize: "12px",
color: "#666",
marginBottom: "4px",
}}
>
:{" "}
<Link
to={`/feeds/${episode.feedId}`}
style={{ color: "#007bff" }}
>
{episode.feedTitle}
</Link>
</div> </div>
)} )}
{episode.articleTitle && episode.articleTitle !== episode.title && ( {episode.articleTitle &&
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}> episode.articleTitle !== episode.title && (
<div
style={{
fontSize: "12px",
color: "#666",
marginBottom: "4px",
}}
>
: <strong>{episode.articleTitle}</strong> : <strong>{episode.articleTitle}</strong>
</div> </div>
)} )}
@ -226,32 +261,46 @@ function EpisodeList() {
href={episode.articleLink} href={episode.articleLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ fontSize: '12px', color: '#666' }} style={{ fontSize: "12px", color: "#666" }}
> >
</a> </a>
)} )}
</td> </td>
<td> <td>
<div style={{ <div
fontSize: '14px', style={{
maxWidth: '200px', fontSize: "14px",
overflow: 'hidden', maxWidth: "200px",
textOverflow: 'ellipsis', overflow: "hidden",
whiteSpace: 'nowrap' textOverflow: "ellipsis",
}}> whiteSpace: "nowrap",
{episode.description || 'No description'} }}
>
{episode.description || "No description"}
</div> </div>
{episode.fileSize && ( {episode.fileSize && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}> <div
style={{
fontSize: "12px",
color: "#666",
marginTop: "4px",
}}
>
{formatFileSize(episode.fileSize)} {formatFileSize(episode.fileSize)}
</div> </div>
)} )}
</td> </td>
<td>{formatDate(episode.createdAt)}</td> <td>{formatDate(episode.createdAt)}</td>
<td> <td>
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}> <div
<div style={{ display: 'flex', gap: '8px' }}> style={{
display: "flex",
gap: "8px",
flexDirection: "column",
}}
>
<div style={{ display: "flex", gap: "8px" }}>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => playAudio(episode.audioPath)} onClick={() => playAudio(episode.audioPath)}
@ -283,7 +332,7 @@ function EpisodeList() {
</tbody> </tbody>
</table> </table>
</div> </div>
) );
} }
export default EpisodeList export default EpisodeList;

View File

@ -1,180 +1,198 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from "react";
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from "react-router-dom";
interface Feed { interface Feed {
id: string id: string;
url: string url: string;
title?: string title?: string;
description?: string description?: string;
lastUpdated?: string lastUpdated?: string;
createdAt: string createdAt: string;
active: boolean active: boolean;
} }
interface EpisodeWithFeedInfo { interface EpisodeWithFeedInfo {
id: string id: string;
title: string title: string;
description?: string description?: string;
audioPath: string audioPath: string;
duration?: number duration?: number;
fileSize?: number fileSize?: number;
createdAt: string createdAt: string;
articleId: string articleId: string;
articleTitle: string articleTitle: string;
articleLink: string articleLink: string;
articlePubDate: string articlePubDate: string;
feedId: string feedId: string;
feedTitle?: string feedTitle?: string;
feedUrl: string feedUrl: string;
} }
function FeedDetail() { function FeedDetail() {
const { feedId } = useParams<{ feedId: string }>() const { feedId } = useParams<{ feedId: string }>();
const [feed, setFeed] = useState<Feed | null>(null) const [feed, setFeed] = useState<Feed | null>(null);
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]) const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<string | null>(null) const [currentAudio, setCurrentAudio] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (feedId) { if (feedId) {
fetchFeedAndEpisodes() fetchFeedAndEpisodes();
} }
}, [feedId]) }, [feedId]);
const fetchFeedAndEpisodes = async () => { const fetchFeedAndEpisodes = async () => {
try { try {
setLoading(true) setLoading(true);
// Fetch feed info and episodes in parallel // Fetch feed info and episodes in parallel
const [feedResponse, episodesResponse] = await Promise.all([ const [feedResponse, episodesResponse] = await Promise.all([
fetch(`/api/feeds/${feedId}`), fetch(`/api/feeds/${feedId}`),
fetch(`/api/feeds/${feedId}/episodes`) fetch(`/api/feeds/${feedId}/episodes`),
]) ]);
if (!feedResponse.ok) { if (!feedResponse.ok) {
const errorData = await feedResponse.json() const errorData = await feedResponse.json();
throw new Error(errorData.error || 'フィード情報の取得に失敗しました') throw new Error(errorData.error || "フィード情報の取得に失敗しました");
} }
if (!episodesResponse.ok) { if (!episodesResponse.ok) {
const errorData = await episodesResponse.json() const errorData = await episodesResponse.json();
throw new Error(errorData.error || 'エピソードの取得に失敗しました') throw new Error(errorData.error || "エピソードの取得に失敗しました");
} }
const feedData = await feedResponse.json() const feedData = await feedResponse.json();
const episodesData = await episodesResponse.json() const episodesData = await episodesResponse.json();
setFeed(feedData.feed) setFeed(feedData.feed);
setEpisodes(episodesData.episodes || []) setEpisodes(episodesData.episodes || []);
} catch (err) { } catch (err) {
console.error('Feed detail fetch error:', err) console.error("Feed detail fetch error:", err);
setError(err instanceof Error ? err.message : 'エラーが発生しました') setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ja-JP') return new Date(dateString).toLocaleString("ja-JP");
} };
const formatFileSize = (bytes?: number) => { const formatFileSize = (bytes?: number) => {
if (!bytes) return '' if (!bytes) return "";
const units = ['B', 'KB', 'MB', 'GB'] const units = ["B", "KB", "MB", "GB"];
let unitIndex = 0 let unitIndex = 0;
let fileSize = bytes let fileSize = bytes;
while (fileSize >= 1024 && unitIndex < units.length - 1) { while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024 fileSize /= 1024;
unitIndex++ unitIndex++;
} }
return `${fileSize.toFixed(1)} ${units[unitIndex]}` return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
} };
const playAudio = (audioPath: string) => { const playAudio = (audioPath: string) => {
if (currentAudio) { if (currentAudio) {
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement const currentPlayer = document.getElementById(
currentAudio,
) as HTMLAudioElement;
if (currentPlayer) { if (currentPlayer) {
currentPlayer.pause() currentPlayer.pause();
currentPlayer.currentTime = 0 currentPlayer.currentTime = 0;
} }
} }
setCurrentAudio(audioPath) setCurrentAudio(audioPath);
} };
const shareEpisode = (episode: EpisodeWithFeedInfo) => { const shareEpisode = (episode: EpisodeWithFeedInfo) => {
const shareUrl = `${window.location.origin}/episode/${episode.id}` const shareUrl = `${window.location.origin}/episode/${episode.id}`;
navigator.clipboard.writeText(shareUrl).then(() => { navigator.clipboard
alert('エピソードリンクをクリップボードにコピーしました') .writeText(shareUrl)
}).catch(() => { .then(() => {
const textArea = document.createElement('textarea') alert("エピソードリンクをクリップボードにコピーしました");
textArea.value = shareUrl
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
alert('エピソードリンクをクリップボードにコピーしました')
}) })
} .catch(() => {
const textArea = document.createElement("textarea");
textArea.value = shareUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
alert("エピソードリンクをクリップボードにコピーしました");
});
};
if (loading) { if (loading) {
return <div className="loading">...</div> return <div className="loading">...</div>;
} }
if (error) { if (error) {
return ( return (
<div className="error"> <div className="error">
{error} {error}
<Link to="/feeds" className="btn btn-secondary" style={{ marginTop: '20px', display: 'block' }}> <Link
to="/feeds"
className="btn btn-secondary"
style={{ marginTop: "20px", display: "block" }}
>
</Link> </Link>
</div> </div>
) );
} }
if (!feed) { if (!feed) {
return ( return (
<div className="error"> <div className="error">
<Link to="/feeds" className="btn btn-secondary" style={{ marginTop: '20px', display: 'block' }}> <Link
to="/feeds"
className="btn btn-secondary"
style={{ marginTop: "20px", display: "block" }}
>
</Link> </Link>
</div> </div>
) );
} }
return ( return (
<div> <div>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: "20px" }}>
<Link to="/feeds" className="btn btn-secondary"> <Link to="/feeds" className="btn btn-secondary">
</Link> </Link>
</div> </div>
<div className="feed-header" style={{ marginBottom: '30px' }}> <div className="feed-header" style={{ marginBottom: "30px" }}>
<h1 style={{ marginBottom: '10px' }}> <h1 style={{ marginBottom: "10px" }}>{feed.title || feed.url}</h1>
{feed.title || feed.url} <div style={{ color: "#666", marginBottom: "10px" }}>
</h1>
<div style={{ color: '#666', marginBottom: '10px' }}>
<a href={feed.url} target="_blank" rel="noopener noreferrer"> <a href={feed.url} target="_blank" rel="noopener noreferrer">
{feed.url} {feed.url}
</a> </a>
</div> </div>
{feed.description && ( {feed.description && (
<div style={{ marginBottom: '15px', color: '#333' }}> <div style={{ marginBottom: "15px", color: "#333" }}>
{feed.description} {feed.description}
</div> </div>
)} )}
<div style={{ fontSize: '14px', color: '#666' }}> <div style={{ fontSize: "14px", color: "#666" }}>
: {formatDate(feed.createdAt)} : {formatDate(feed.createdAt)}
{feed.lastUpdated && ` | 最終更新: ${formatDate(feed.lastUpdated)}`} {feed.lastUpdated && ` | 最終更新: ${formatDate(feed.lastUpdated)}`}
</div> </div>
</div> </div>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div
style={{
marginBottom: "20px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2> ({episodes.length})</h2> <h2> ({episodes.length})</h2>
<button className="btn btn-secondary" onClick={fetchFeedAndEpisodes}> <button className="btn btn-secondary" onClick={fetchFeedAndEpisodes}>
@ -190,27 +208,33 @@ function FeedDetail() {
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th style={{ width: '35%' }}></th> <th style={{ width: "35%" }}></th>
<th style={{ width: '25%' }}></th> <th style={{ width: "25%" }}></th>
<th style={{ width: '15%' }}></th> <th style={{ width: "15%" }}></th>
<th style={{ width: '25%' }}></th> <th style={{ width: "25%" }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{episodes.map((episode) => ( {episodes.map((episode) => (
<tr key={episode.id}> <tr key={episode.id}>
<td> <td>
<div style={{ marginBottom: '8px' }}> <div style={{ marginBottom: "8px" }}>
<strong> <strong>
<Link <Link
to={`/episode/${episode.id}`} to={`/episode/${episode.id}`}
style={{ textDecoration: 'none', color: '#007bff' }} style={{ textDecoration: "none", color: "#007bff" }}
> >
{episode.title} {episode.title}
</Link> </Link>
</strong> </strong>
</div> </div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}> <div
style={{
fontSize: "12px",
color: "#666",
marginBottom: "4px",
}}
>
: <strong>{episode.articleTitle}</strong> : <strong>{episode.articleTitle}</strong>
</div> </div>
{episode.articleLink && ( {episode.articleLink && (
@ -218,32 +242,46 @@ function FeedDetail() {
href={episode.articleLink} href={episode.articleLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ fontSize: '12px', color: '#666' }} style={{ fontSize: "12px", color: "#666" }}
> >
</a> </a>
)} )}
</td> </td>
<td> <td>
<div style={{ <div
fontSize: '14px', style={{
maxWidth: '200px', fontSize: "14px",
overflow: 'hidden', maxWidth: "200px",
textOverflow: 'ellipsis', overflow: "hidden",
whiteSpace: 'nowrap' textOverflow: "ellipsis",
}}> whiteSpace: "nowrap",
{episode.description || 'No description'} }}
>
{episode.description || "No description"}
</div> </div>
{episode.fileSize && ( {episode.fileSize && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}> <div
style={{
fontSize: "12px",
color: "#666",
marginTop: "4px",
}}
>
{formatFileSize(episode.fileSize)} {formatFileSize(episode.fileSize)}
</div> </div>
)} )}
</td> </td>
<td>{formatDate(episode.createdAt)}</td> <td>{formatDate(episode.createdAt)}</td>
<td> <td>
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}> <div
<div style={{ display: 'flex', gap: '8px' }}> style={{
display: "flex",
gap: "8px",
flexDirection: "column",
}}
>
<div style={{ display: "flex", gap: "8px" }}>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => playAudio(episode.audioPath)} onClick={() => playAudio(episode.audioPath)}
@ -276,7 +314,7 @@ function FeedDetail() {
</table> </table>
)} )}
</div> </div>
) );
} }
export default FeedDetail export default FeedDetail;

View File

@ -1,74 +1,83 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from "react";
import { Link } from 'react-router-dom' import { Link } from "react-router-dom";
interface Feed { interface Feed {
id: string id: string;
url: string url: string;
title?: string title?: string;
description?: string description?: string;
lastUpdated?: string lastUpdated?: string;
createdAt: string createdAt: string;
active: boolean active: boolean;
} }
function FeedList() { function FeedList() {
const [feeds, setFeeds] = useState<Feed[]>([]) const [feeds, setFeeds] = useState<Feed[]>([]);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchFeeds() fetchFeeds();
}, []) }, []);
const fetchFeeds = async () => { const fetchFeeds = async () => {
try { try {
setLoading(true) setLoading(true);
const response = await fetch('/api/feeds') const response = await fetch("/api/feeds");
if (!response.ok) { if (!response.ok) {
const errorData = await response.json() const errorData = await response.json();
throw new Error(errorData.error || 'フィードの取得に失敗しました') throw new Error(errorData.error || "フィードの取得に失敗しました");
} }
const data = await response.json() const data = await response.json();
setFeeds(data.feeds || []) setFeeds(data.feeds || []);
} catch (err) { } catch (err) {
console.error('Feed fetch error:', err) console.error("Feed fetch error:", err);
setError(err instanceof Error ? err.message : 'エラーが発生しました') setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ja-JP') return new Date(dateString).toLocaleString("ja-JP");
} };
if (loading) { if (loading) {
return <div className="loading">...</div> return <div className="loading">...</div>;
} }
if (error) { if (error) {
return <div className="error">{error}</div> return <div className="error">{error}</div>;
} }
if (feeds.length === 0) { if (feeds.length === 0) {
return ( return (
<div className="empty-state"> <div className="empty-state">
<p></p> <p></p>
<p>RSSフィードをリクエストするか</p> <p>
RSSフィードをリクエストするか
</p>
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={fetchFeeds} onClick={fetchFeeds}
style={{ marginTop: '10px' }} style={{ marginTop: "10px" }}
> >
</button> </button>
</div> </div>
) );
} }
return ( return (
<div> <div>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div
style={{
marginBottom: "20px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<h2> ({feeds.length})</h2> <h2> ({feeds.length})</h2>
<button className="btn btn-secondary" onClick={fetchFeeds}> <button className="btn btn-secondary" onClick={fetchFeeds}>
@ -92,9 +101,7 @@ function FeedList() {
</div> </div>
{feed.description && ( {feed.description && (
<div className="feed-description"> <div className="feed-description">{feed.description}</div>
{feed.description}
</div>
)} )}
<div className="feed-meta"> <div className="feed-meta">
@ -182,7 +189,7 @@ function FeedList() {
} }
`}</style> `}</style>
</div> </div>
) );
} }
export default FeedList export default FeedList;

View File

@ -1,55 +1,57 @@
import { useState } from 'react' import { useState } from "react";
function FeedManager() { function FeedManager() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null);
const [newFeedUrl, setNewFeedUrl] = useState('') const [newFeedUrl, setNewFeedUrl] = useState("");
const [requestMessage, setRequestMessage] = useState('') const [requestMessage, setRequestMessage] = useState("");
const [requesting, setRequesting] = useState(false) const [requesting, setRequesting] = useState(false);
const submitRequest = async (e: React.FormEvent) => { const submitRequest = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
if (!newFeedUrl.trim()) return if (!newFeedUrl.trim()) return;
try { try {
setRequesting(true) setRequesting(true);
setError(null) setError(null);
setSuccess(null) setSuccess(null);
const response = await fetch('/api/feed-requests', { const response = await fetch("/api/feed-requests", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
url: newFeedUrl.trim(), url: newFeedUrl.trim(),
requestMessage: requestMessage.trim() || undefined requestMessage: requestMessage.trim() || undefined,
}), }),
}) });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json() const errorData = await response.json();
throw new Error(errorData.error || 'リクエストの送信に失敗しました') throw new Error(errorData.error || "リクエストの送信に失敗しました");
} }
setSuccess('フィードリクエストを送信しました。管理者の承認をお待ちください。') setSuccess(
setNewFeedUrl('') "フィードリクエストを送信しました。管理者の承認をお待ちください。",
setRequestMessage('') );
setNewFeedUrl("");
setRequestMessage("");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'エラーが発生しました') setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally { } finally {
setRequesting(false) setRequesting(false);
}
} }
};
return ( return (
<div> <div>
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
{success && <div className="success">{success}</div>} {success && <div className="success">{success}</div>}
<div style={{ marginBottom: '30px' }}> <div style={{ marginBottom: "30px" }}>
<h2></h2> <h2></h2>
<p style={{ color: '#666', marginBottom: '20px' }}> <p style={{ color: "#666", marginBottom: "20px" }}>
RSSフィードのURLを送信してください RSSフィードのURLを送信してください
</p> </p>
@ -74,7 +76,7 @@ function FeedManager() {
onChange={(e) => setRequestMessage(e.target.value)} onChange={(e) => setRequestMessage(e.target.value)}
placeholder="このフィードについての説明や追加理由があれば記載してください" placeholder="このフィードについての説明や追加理由があれば記載してください"
rows={3} rows={3}
style={{ resize: 'vertical', minHeight: '80px' }} style={{ resize: "vertical", minHeight: "80px" }}
/> />
</div> </div>
@ -83,22 +85,30 @@ function FeedManager() {
className="btn btn-primary" className="btn btn-primary"
disabled={requesting} disabled={requesting}
> >
{requesting ? 'リクエスト送信中...' : 'フィードをリクエスト'} {requesting ? "リクエスト送信中..." : "フィードをリクエスト"}
</button> </button>
</form> </form>
</div> </div>
<div style={{ backgroundColor: '#f8f9fa', padding: '20px', borderRadius: '8px' }}> <div
<h3 style={{ marginBottom: '15px' }}></h3> style={{
<ul style={{ paddingLeft: '20px', color: '#666' }}> backgroundColor: "#f8f9fa",
padding: "20px",
borderRadius: "8px",
}}
>
<h3 style={{ marginBottom: "15px" }}></h3>
<ul style={{ paddingLeft: "20px", color: "#666" }}>
<li></li> <li></li>
<li>RSSフィードと判断された場合</li> <li>
RSSフィードと判断された場合
</li>
<li></li> <li></li>
<li></li> <li></li>
</ul> </ul>
</div> </div>
</div> </div>
) );
} }
export default FeedManager export default FeedManager;

View File

@ -1,13 +1,13 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from "react-router-dom";
import App from './App.tsx' import App from "./App.tsx";
import './styles.css' import "./styles.css";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,
) );

View File

@ -7,12 +7,19 @@
"build:frontend": "cd frontend && bun vite build", "build:frontend": "cd frontend && bun vite build",
"build:admin": "cd admin-panel && bun install && bun run build", "build:admin": "cd admin-panel && bun install && bun run build",
"dev:frontend": "cd frontend && bun vite dev", "dev:frontend": "cd frontend && bun vite dev",
"dev:admin": "cd admin-panel && bun vite dev" "dev:admin": "cd admin-panel && bun vite dev",
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"check": "biome check .",
"check:fix": "biome check --write ."
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-polly": "^3.823.0", "@aws-sdk/client-polly": "^3.823.0",
"@hono/node-server": "^1.14.3", "@hono/node-server": "^1.14.3",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
"cheerio": "^1.0.0",
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
"hono": "^4.7.11", "hono": "^4.7.11",
"openai": "^4.104.0", "openai": "^4.104.0",
@ -24,7 +31,9 @@
}, },
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "latest", "@types/bun": "latest",
"@types/cheerio": "^1.0.0",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"@types/react": "^19.1.6", "@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",

View File

@ -39,7 +39,7 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
// Check for cancellation at start // Check for cancellation at start
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Batch process was cancelled before starting'); throw new Error("Batch process was cancelled before starting");
} }
// Load feed URLs from file // Load feed URLs from file
@ -55,14 +55,17 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
for (const url of feedUrls) { for (const url of feedUrls) {
// Check for cancellation before processing each feed // Check for cancellation before processing each feed
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Batch process was cancelled during feed processing'); throw new Error("Batch process was cancelled during feed processing");
} }
try { try {
await processFeedUrl(url, abortSignal); await processFeedUrl(url, abortSignal);
} catch (error) { } catch (error) {
// Re-throw cancellation errors // Re-throw cancellation errors
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) { if (
error instanceof Error &&
(error.message.includes("cancelled") || error.name === "AbortError")
) {
throw error; throw error;
} }
console.error(`❌ Failed to process feed ${url}:`, error); console.error(`❌ Failed to process feed ${url}:`, error);
@ -72,7 +75,7 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
// Check for cancellation before processing articles // Check for cancellation before processing articles
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Batch process was cancelled before article processing'); throw new Error("Batch process was cancelled before article processing");
} }
// Process unprocessed articles and generate podcasts // Process unprocessed articles and generate podcasts
@ -83,10 +86,13 @@ export async function batchProcess(abortSignal?: AbortSignal): Promise<void> {
new Date().toISOString(), new Date().toISOString(),
); );
} catch (error) { } catch (error) {
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) { if (
error instanceof Error &&
(error.message.includes("cancelled") || error.name === "AbortError")
) {
console.log("🛑 Batch process was cancelled"); console.log("🛑 Batch process was cancelled");
const abortError = new Error('Batch process was cancelled'); const abortError = new Error("Batch process was cancelled");
abortError.name = 'AbortError'; abortError.name = "AbortError";
throw abortError; throw abortError;
} }
console.error("💥 Batch process failed:", error); console.error("💥 Batch process failed:", error);
@ -116,14 +122,17 @@ async function loadFeedUrls(): Promise<string[]> {
/** /**
* Process a single feed URL and discover new articles * Process a single feed URL and discover new articles
*/ */
async function processFeedUrl(url: string, abortSignal?: AbortSignal): Promise<void> { async function processFeedUrl(
url: string,
abortSignal?: AbortSignal,
): Promise<void> {
if (!url || !url.startsWith("http")) { if (!url || !url.startsWith("http")) {
throw new Error(`Invalid feed URL: ${url}`); throw new Error(`Invalid feed URL: ${url}`);
} }
// Check for cancellation // Check for cancellation
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Feed processing was cancelled'); throw new Error("Feed processing was cancelled");
} }
console.log(`🔍 Processing feed: ${url}`); console.log(`🔍 Processing feed: ${url}`);
@ -135,7 +144,7 @@ async function processFeedUrl(url: string, abortSignal?: AbortSignal): Promise<v
// Check for cancellation after parsing // Check for cancellation after parsing
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Feed processing was cancelled'); throw new Error("Feed processing was cancelled");
} }
// Get or create feed record // Get or create feed record
@ -225,13 +234,15 @@ async function discoverNewArticles(
/** /**
* Process unprocessed articles and generate podcasts * Process unprocessed articles and generate podcasts
*/ */
async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<void> { async function processUnprocessedArticles(
abortSignal?: AbortSignal,
): Promise<void> {
console.log("🎧 Processing unprocessed articles..."); console.log("🎧 Processing unprocessed articles...");
try { try {
// Check for cancellation // Check for cancellation
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Article processing was cancelled'); throw new Error("Article processing was cancelled");
} }
// Process retry queue first // Process retry queue first
@ -239,7 +250,7 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
// Check for cancellation after retry queue // Check for cancellation after retry queue
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Article processing was cancelled'); throw new Error("Article processing was cancelled");
} }
// Get unprocessed articles (limit to prevent overwhelming) // Get unprocessed articles (limit to prevent overwhelming)
@ -260,11 +271,14 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
for (const article of unprocessedArticles) { for (const article of unprocessedArticles) {
// Check for cancellation before processing each article // Check for cancellation before processing each article
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Article processing was cancelled'); throw new Error("Article processing was cancelled");
} }
try { try {
const episodeCreated = await generatePodcastForArticle(article, abortSignal); const episodeCreated = await generatePodcastForArticle(
article,
abortSignal,
);
// Only mark as processed and update RSS if episode was actually created // Only mark as processed and update RSS if episode was actually created
if (episodeCreated) { if (episodeCreated) {
@ -273,18 +287,28 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
successfullyGeneratedCount++; successfullyGeneratedCount++;
// Update RSS immediately after each successful episode creation // Update RSS immediately after each successful episode creation
console.log(`📻 Updating podcast RSS after successful episode creation...`); console.log(
`📻 Updating podcast RSS after successful episode creation...`,
);
try { try {
await updatePodcastRSS(); await updatePodcastRSS();
console.log(`📻 RSS updated successfully for: ${article.title}`); console.log(`📻 RSS updated successfully for: ${article.title}`);
} catch (rssError) { } catch (rssError) {
console.error(`❌ Failed to update RSS after episode creation for: ${article.title}`, rssError); console.error(
`❌ Failed to update RSS after episode creation for: ${article.title}`,
rssError,
);
} }
} else { } else {
console.warn(`⚠️ Episode creation failed for: ${article.title} - not marking as processed`); console.warn(
`⚠️ Episode creation failed for: ${article.title} - not marking as processed`,
);
} }
} catch (error) { } catch (error) {
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) { if (
error instanceof Error &&
(error.message.includes("cancelled") || error.name === "AbortError")
) {
console.log(`🛑 Article processing cancelled, stopping batch`); console.log(`🛑 Article processing cancelled, stopping batch`);
throw error; // Re-throw to propagate cancellation throw error; // Re-throw to propagate cancellation
} }
@ -296,7 +320,9 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
} }
} }
console.log(`🎯 Batch processing completed: ${successfullyGeneratedCount} episodes successfully created`); console.log(
`🎯 Batch processing completed: ${successfullyGeneratedCount} episodes successfully created`,
);
if (successfullyGeneratedCount === 0) { if (successfullyGeneratedCount === 0) {
console.log(` No episodes were successfully created in this batch`); console.log(` No episodes were successfully created in this batch`);
} }
@ -310,7 +336,8 @@ async function processUnprocessedArticles(abortSignal?: AbortSignal): Promise<vo
* Process retry queue for failed TTS generation * Process retry queue for failed TTS generation
*/ */
async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> { async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
const { getQueueItems, updateQueueItemStatus, removeFromQueue } = await import("../services/database.js"); const { getQueueItems, updateQueueItemStatus, removeFromQueue } =
await import("../services/database.js");
const { Database } = await import("bun:sqlite"); const { Database } = await import("bun:sqlite");
const db = new Database(config.paths.dbPath); const db = new Database(config.paths.dbPath);
@ -328,17 +355,23 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
for (const item of queueItems) { for (const item of queueItems) {
// Check for cancellation before processing each retry item // Check for cancellation before processing each retry item
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Retry queue processing was cancelled'); throw new Error("Retry queue processing was cancelled");
} }
try { try {
console.log(`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1}/3)`); console.log(
`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1}/3)`,
);
// Mark as processing // Mark as processing
await updateQueueItemStatus(item.id, 'processing'); await updateQueueItemStatus(item.id, "processing");
// Attempt TTS generation without re-queuing on failure // Attempt TTS generation without re-queuing on failure
const audioFilePath = await generateTTSWithoutQueue(item.itemId, item.scriptText, item.retryCount); const audioFilePath = await generateTTSWithoutQueue(
item.itemId,
item.scriptText,
item.retryCount,
);
// Success - remove from queue and update RSS // Success - remove from queue and update RSS
await removeFromQueue(item.id); await removeFromQueue(item.id);
@ -348,13 +381,20 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
console.log(`📻 Updating podcast RSS after successful retry...`); console.log(`📻 Updating podcast RSS after successful retry...`);
try { try {
await updatePodcastRSS(); await updatePodcastRSS();
console.log(`📻 RSS updated successfully after retry for: ${item.itemId}`); console.log(
`📻 RSS updated successfully after retry for: ${item.itemId}`,
);
} catch (rssError) { } catch (rssError) {
console.error(`❌ Failed to update RSS after retry for: ${item.itemId}`, rssError); console.error(
`❌ Failed to update RSS after retry for: ${item.itemId}`,
rssError,
);
} }
} catch (error) { } catch (error) {
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) { if (
error instanceof Error &&
(error.message.includes("cancelled") || error.name === "AbortError")
) {
console.log(`🛑 TTS retry processing cancelled for: ${item.itemId}`); console.log(`🛑 TTS retry processing cancelled for: ${item.itemId}`);
throw error; // Re-throw cancellation errors throw error; // Re-throw cancellation errors
} }
@ -364,17 +404,26 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
try { try {
if (item.retryCount >= 2) { if (item.retryCount >= 2) {
// Max retries reached, mark as failed // Max retries reached, mark as failed
await updateQueueItemStatus(item.id, 'failed'); await updateQueueItemStatus(item.id, "failed");
console.log(`💀 Max retries reached for: ${item.itemId}, marking as failed`); console.log(
`💀 Max retries reached for: ${item.itemId}, marking as failed`,
);
} else { } else {
// Increment retry count and reset to pending for next retry // Increment retry count and reset to pending for next retry
const updatedRetryCount = item.retryCount + 1; const updatedRetryCount = item.retryCount + 1;
const stmt = db.prepare("UPDATE tts_queue SET retry_count = ?, status = 'pending' WHERE id = ?"); const stmt = db.prepare(
"UPDATE tts_queue SET retry_count = ?, status = 'pending' WHERE id = ?",
);
stmt.run(updatedRetryCount, item.id); stmt.run(updatedRetryCount, item.id);
console.log(`🔄 Updated retry count to ${updatedRetryCount} for: ${item.itemId}`); console.log(
`🔄 Updated retry count to ${updatedRetryCount} for: ${item.itemId}`,
);
} }
} catch (dbError) { } catch (dbError) {
console.error(`❌ Failed to update queue status for: ${item.itemId}`, dbError); console.error(
`❌ Failed to update queue status for: ${item.itemId}`,
dbError,
);
} }
} }
} }
@ -386,7 +435,10 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
try { try {
db.close(); db.close();
} catch (closeError) { } catch (closeError) {
console.warn("⚠️ Warning: Failed to close database connection:", closeError); console.warn(
"⚠️ Warning: Failed to close database connection:",
closeError,
);
} }
} }
} }
@ -395,13 +447,16 @@ async function processRetryQueue(abortSignal?: AbortSignal): Promise<void> {
* Generate podcast for a single article * Generate podcast for a single article
* Returns true if episode was successfully created, false otherwise * Returns true if episode was successfully created, false otherwise
*/ */
async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal): Promise<boolean> { async function generatePodcastForArticle(
article: any,
abortSignal?: AbortSignal,
): Promise<boolean> {
console.log(`🎤 Generating podcast for: ${article.title}`); console.log(`🎤 Generating podcast for: ${article.title}`);
try { try {
// Check for cancellation // Check for cancellation
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Podcast generation was cancelled'); throw new Error("Podcast generation was cancelled");
} }
// Get feed information for context // Get feed information for context
@ -410,7 +465,7 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
// Check for cancellation before classification // Check for cancellation before classification
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Podcast generation was cancelled'); throw new Error("Podcast generation was cancelled");
} }
// Classify the article/feed // Classify the article/feed
@ -421,7 +476,7 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
// Check for cancellation before content generation // Check for cancellation before content generation
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Podcast generation was cancelled'); throw new Error("Podcast generation was cancelled");
} }
// Enhance article content with web scraping if needed // Enhance article content with web scraping if needed
@ -430,7 +485,7 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
article.title, article.title,
article.link, article.link,
article.content, article.content,
article.description article.description,
); );
// Generate podcast content for this single article // Generate podcast content for this single article
@ -445,7 +500,7 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
// Check for cancellation before TTS // Check for cancellation before TTS
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
throw new Error('Podcast generation was cancelled'); throw new Error("Podcast generation was cancelled");
} }
// Generate unique ID for the episode // Generate unique ID for the episode
@ -460,13 +515,18 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
console.error(`❌ TTS generation failed for ${article.title}:`, ttsError); console.error(`❌ TTS generation failed for ${article.title}:`, ttsError);
// Check if error indicates item was added to retry queue // Check if error indicates item was added to retry queue
const errorMessage = ttsError instanceof Error ? ttsError.message : String(ttsError); const errorMessage =
if (errorMessage.includes('added to retry queue')) { ttsError instanceof Error ? ttsError.message : String(ttsError);
console.log(`📋 Article will be retried later via TTS queue: ${article.title}`); if (errorMessage.includes("added to retry queue")) {
console.log(
`📋 Article will be retried later via TTS queue: ${article.title}`,
);
// Don't mark as processed - leave it for retry // Don't mark as processed - leave it for retry
return false; return false;
} else { } else {
console.error(`💀 TTS generation permanently failed for ${article.title} - max retries exceeded`); console.error(
`💀 TTS generation permanently failed for ${article.title} - max retries exceeded`,
);
// Max retries exceeded, don't create episode but mark as processed to avoid infinite retry // Max retries exceeded, don't create episode but mark as processed to avoid infinite retry
return false; return false;
} }
@ -476,11 +536,16 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
try { try {
const audioStats = await getAudioFileStats(audioFilePath); const audioStats = await getAudioFileStats(audioFilePath);
if (audioStats.size === 0) { if (audioStats.size === 0) {
console.error(`❌ Audio file is empty for ${article.title}: ${audioFilePath}`); console.error(
`❌ Audio file is empty for ${article.title}: ${audioFilePath}`,
);
return false; return false;
} }
} catch (statsError) { } catch (statsError) {
console.error(`❌ Cannot access audio file for ${article.title}: ${audioFilePath}`, statsError); console.error(
`❌ Cannot access audio file for ${article.title}: ${audioFilePath}`,
statsError,
);
return false; return false;
} }
@ -502,11 +567,17 @@ async function generatePodcastForArticle(article: any, abortSignal?: AbortSignal
console.log(`💾 Episode saved for article: ${article.title}`); console.log(`💾 Episode saved for article: ${article.title}`);
return true; return true;
} catch (saveError) { } catch (saveError) {
console.error(`❌ Failed to save episode for ${article.title}:`, saveError); console.error(
`❌ Failed to save episode for ${article.title}:`,
saveError,
);
return false; return false;
} }
} catch (error) { } catch (error) {
if (error instanceof Error && (error.message.includes('cancelled') || error.name === 'AbortError')) { if (
error instanceof Error &&
(error.message.includes("cancelled") || error.name === "AbortError")
) {
console.log(`🛑 Podcast generation cancelled for: ${article.title}`); console.log(`🛑 Podcast generation cancelled for: ${article.title}`);
throw error; // Re-throw cancellation errors to stop the batch throw error; // Re-throw cancellation errors to stop the batch
} }

View File

@ -48,7 +48,10 @@ app.get("/podcast_audio/*", async (c) => {
return c.notFound(); return c.notFound();
} }
const audioFilePath = path.join(config.paths.podcastAudioDir, audioFileName); const audioFilePath = path.join(
config.paths.podcastAudioDir,
audioFileName,
);
const file = Bun.file(audioFilePath); const file = Bun.file(audioFilePath);
if (await file.exists()) { if (await file.exists()) {
@ -83,7 +86,6 @@ app.get("/podcast.xml", async (c) => {
} }
}); });
// Frontend fallback routes // Frontend fallback routes
async function serveIndex(c: any) { async function serveIndex(c: any) {
try { try {
@ -110,7 +112,9 @@ app.get("/index.html", serveIndex);
// API endpoints for frontend // API endpoints for frontend
app.get("/api/episodes", async (c) => { app.get("/api/episodes", async (c) => {
try { try {
const { fetchEpisodesWithFeedInfo } = await import("./services/database.js"); const { fetchEpisodesWithFeedInfo } = await import(
"./services/database.js"
);
const episodes = await fetchEpisodesWithFeedInfo(); const episodes = await fetchEpisodesWithFeedInfo();
return c.json({ episodes }); return c.json({ episodes });
} catch (error) { } catch (error) {
@ -131,7 +135,7 @@ app.get("/api/episodes-from-xml", async (c) => {
} }
// Read and parse XML // Read and parse XML
const xmlContent = fs.readFileSync(podcastXmlPath, 'utf-8'); const xmlContent = fs.readFileSync(podcastXmlPath, "utf-8");
const parser = new xml2js.Parser(); const parser = new xml2js.Parser();
const result = await parser.parseStringPromise(xmlContent); const result = await parser.parseStringPromise(xmlContent);
@ -141,13 +145,13 @@ app.get("/api/episodes-from-xml", async (c) => {
for (const item of items) { for (const item of items) {
const episode = { const episode = {
id: generateEpisodeId(item), id: generateEpisodeId(item),
title: item.title?.[0] || 'Untitled', title: item.title?.[0] || "Untitled",
description: item.description?.[0] || '', description: item.description?.[0] || "",
pubDate: item.pubDate?.[0] || '', pubDate: item.pubDate?.[0] || "",
audioUrl: item.enclosure?.[0]?.$?.url || '', audioUrl: item.enclosure?.[0]?.$?.url || "",
audioLength: item.enclosure?.[0]?.$?.length || '0', audioLength: item.enclosure?.[0]?.$?.length || "0",
guid: item.guid?.[0] || '', guid: item.guid?.[0] || "",
link: item.link?.[0] || '' link: item.link?.[0] || "",
}; };
episodes.push(episode); episodes.push(episode);
} }
@ -163,18 +167,19 @@ app.get("/api/episodes-from-xml", async (c) => {
function generateEpisodeId(item: any): string { function generateEpisodeId(item: any): string {
// Use GUID if available, otherwise generate from title and audio URL // Use GUID if available, otherwise generate from title and audio URL
if (item.guid?.[0]) { if (item.guid?.[0]) {
return encodeURIComponent(item.guid[0].replace(/[^a-zA-Z0-9-_]/g, '-')); return encodeURIComponent(item.guid[0].replace(/[^a-zA-Z0-9-_]/g, "-"));
} }
const title = item.title?.[0] || ''; const title = item.title?.[0] || "";
const audioUrl = item.enclosure?.[0]?.$?.url || ''; const audioUrl = item.enclosure?.[0]?.$?.url || "";
const titleSlug = title.toLowerCase() const titleSlug = title
.replace(/[^a-zA-Z0-9\s]/g, '') .toLowerCase()
.replace(/\s+/g, '-') .replace(/[^a-zA-Z0-9\s]/g, "")
.replace(/\s+/g, "-")
.substring(0, 50); .substring(0, 50);
// Extract filename from audio URL as fallback // Extract filename from audio URL as fallback
const audioFilename = audioUrl.split('/').pop()?.split('.')[0] || 'episode'; const audioFilename = audioUrl.split("/").pop()?.split(".")[0] || "episode";
return titleSlug || audioFilename; return titleSlug || audioFilename;
} }
@ -190,12 +195,14 @@ app.get("/api/episode/:episodeId", async (c) => {
return c.json({ error: "podcast.xml not found" }, 404); return c.json({ error: "podcast.xml not found" }, 404);
} }
const xmlContent = fs.readFileSync(podcastXmlPath, 'utf-8'); const xmlContent = fs.readFileSync(podcastXmlPath, "utf-8");
const parser = new xml2js.Parser(); const parser = new xml2js.Parser();
const result = await parser.parseStringPromise(xmlContent); const result = await parser.parseStringPromise(xmlContent);
const items = result?.rss?.channel?.[0]?.item || []; const items = result?.rss?.channel?.[0]?.item || [];
const targetItem = items.find((item: any) => generateEpisodeId(item) === episodeId); const targetItem = items.find(
(item: any) => generateEpisodeId(item) === episodeId,
);
if (!targetItem) { if (!targetItem) {
return c.json({ error: "Episode not found" }, 404); return c.json({ error: "Episode not found" }, 404);
@ -203,13 +210,13 @@ app.get("/api/episode/:episodeId", async (c) => {
const episode = { const episode = {
id: episodeId, id: episodeId,
title: targetItem.title?.[0] || 'Untitled', title: targetItem.title?.[0] || "Untitled",
description: targetItem.description?.[0] || '', description: targetItem.description?.[0] || "",
pubDate: targetItem.pubDate?.[0] || '', pubDate: targetItem.pubDate?.[0] || "",
audioUrl: targetItem.enclosure?.[0]?.$?.url || '', audioUrl: targetItem.enclosure?.[0]?.$?.url || "",
audioLength: targetItem.enclosure?.[0]?.$?.length || '0', audioLength: targetItem.enclosure?.[0]?.$?.length || "0",
guid: targetItem.guid?.[0] || '', guid: targetItem.guid?.[0] || "",
link: targetItem.link?.[0] || '' link: targetItem.link?.[0] || "",
}; };
return c.json({ episode }); return c.json({ episode });
@ -261,7 +268,9 @@ app.get("/api/feeds/:feedId/episodes", async (c) => {
app.get("/api/episodes-with-feed-info", async (c) => { app.get("/api/episodes-with-feed-info", async (c) => {
try { try {
const { fetchEpisodesWithFeedInfo } = await import("./services/database.js"); const { fetchEpisodesWithFeedInfo } = await import(
"./services/database.js"
);
const episodes = await fetchEpisodesWithFeedInfo(); const episodes = await fetchEpisodesWithFeedInfo();
return c.json({ episodes }); return c.json({ episodes });
} catch (error) { } catch (error) {
@ -273,7 +282,9 @@ app.get("/api/episodes-with-feed-info", async (c) => {
app.get("/api/episode-with-source/:episodeId", async (c) => { app.get("/api/episode-with-source/:episodeId", async (c) => {
try { try {
const episodeId = c.req.param("episodeId"); const episodeId = c.req.param("episodeId");
const { fetchEpisodeWithSourceInfo } = await import("./services/database.js"); const { fetchEpisodeWithSourceInfo } = await import(
"./services/database.js"
);
const episode = await fetchEpisodeWithSourceInfo(episodeId); const episode = await fetchEpisodeWithSourceInfo(episodeId);
if (!episode) { if (!episode) {
@ -292,7 +303,7 @@ app.post("/api/feed-requests", async (c) => {
const body = await c.req.json(); const body = await c.req.json();
const { url, requestMessage } = body; const { url, requestMessage } = body;
if (!url || typeof url !== 'string') { if (!url || typeof url !== "string") {
return c.json({ error: "URL is required" }, 400); return c.json({ error: "URL is required" }, 400);
} }
@ -305,7 +316,7 @@ app.post("/api/feed-requests", async (c) => {
return c.json({ return c.json({
success: true, success: true,
message: "Feed request submitted successfully", message: "Feed request submitted successfully",
requestId requestId,
}); });
} catch (error) { } catch (error) {
console.error("Error submitting feed request:", error); console.error("Error submitting feed request:", error);
@ -329,6 +340,8 @@ serve(
console.log(`🌟 Server is running on http://localhost:${info.port}`); console.log(`🌟 Server is running on http://localhost:${info.port}`);
console.log(`📡 Using configuration from: ${config.paths.projectRoot}`); console.log(`📡 Using configuration from: ${config.paths.projectRoot}`);
console.log(`🗄️ Database: ${config.paths.dbPath}`); console.log(`🗄️ Database: ${config.paths.dbPath}`);
console.log(`🔄 Batch scheduler is active and will manage automatic processing`); console.log(
`🔄 Batch scheduler is active and will manage automatic processing`,
);
}, },
); );

View File

@ -58,7 +58,7 @@ class BatchScheduler {
this.state.nextRun = new Date(nextRunTime).toISOString(); this.state.nextRun = new Date(nextRunTime).toISOString();
console.log( console.log(
`🕕 Next batch process scheduled for: ${new Date(nextRunTime).toLocaleString()}` `🕕 Next batch process scheduled for: ${new Date(nextRunTime).toLocaleString()}`,
); );
this.state.intervalId = setTimeout(async () => { this.state.intervalId = setTimeout(async () => {
@ -87,7 +87,7 @@ class BatchScheduler {
await batchProcess(this.currentAbortController.signal); await batchProcess(this.currentAbortController.signal);
console.log("✅ Scheduled batch process completed"); console.log("✅ Scheduled batch process completed");
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === "AbortError") {
console.log("🛑 Batch process was forcefully stopped"); console.log("🛑 Batch process was forcefully stopped");
} else { } else {
console.error("❌ Error during scheduled batch process:", error); console.error("❌ Error during scheduled batch process:", error);

View File

@ -64,14 +64,19 @@ function getOptionalEnv(key: string, defaultValue: string): string {
} }
function createConfig(): Config { function createConfig(): Config {
const projectRoot = import.meta.dirname ? path.dirname(import.meta.dirname) : process.cwd(); const projectRoot = import.meta.dirname
? path.dirname(import.meta.dirname)
: process.cwd();
const dataDir = path.join(projectRoot, "data"); const dataDir = path.join(projectRoot, "data");
const publicDir = path.join(projectRoot, "public"); const publicDir = path.join(projectRoot, "public");
return { return {
openai: { openai: {
apiKey: getRequiredEnv("OPENAI_API_KEY"), apiKey: getRequiredEnv("OPENAI_API_KEY"),
endpoint: getOptionalEnv("OPENAI_API_ENDPOINT", "https://api.openai.com/v1"), endpoint: getOptionalEnv(
"OPENAI_API_ENDPOINT",
"https://api.openai.com/v1",
),
modelName: getOptionalEnv("OPENAI_MODEL_NAME", "gpt-4o-mini"), modelName: getOptionalEnv("OPENAI_MODEL_NAME", "gpt-4o-mini"),
}, },
@ -83,7 +88,10 @@ function createConfig(): Config {
podcast: { podcast: {
title: getOptionalEnv("PODCAST_TITLE", "自動生成ポッドキャスト"), title: getOptionalEnv("PODCAST_TITLE", "自動生成ポッドキャスト"),
link: getOptionalEnv("PODCAST_LINK", "https://your-domain.com/podcast"), link: getOptionalEnv("PODCAST_LINK", "https://your-domain.com/podcast"),
description: getOptionalEnv("PODCAST_DESCRIPTION", "RSSフィードから自動生成された音声ポッドキャスト"), description: getOptionalEnv(
"PODCAST_DESCRIPTION",
"RSSフィードから自動生成された音声ポッドキャスト",
),
language: getOptionalEnv("PODCAST_LANGUAGE", "ja"), language: getOptionalEnv("PODCAST_LANGUAGE", "ja"),
author: getOptionalEnv("PODCAST_AUTHOR", "管理者"), author: getOptionalEnv("PODCAST_AUTHOR", "管理者"),
categories: getOptionalEnv("PODCAST_CATEGORIES", "Technology"), categories: getOptionalEnv("PODCAST_CATEGORIES", "Technology"),
@ -98,7 +106,8 @@ function createConfig(): Config {
}, },
batch: { batch: {
disableInitialRun: getOptionalEnv("DISABLE_INITIAL_BATCH", "false") === "true", disableInitialRun:
getOptionalEnv("DISABLE_INITIAL_BATCH", "false") === "true",
}, },
paths: { paths: {
@ -109,7 +118,10 @@ function createConfig(): Config {
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"), 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"),
),
}, },
}; };
} }

View File

@ -1,4 +1,4 @@
import * as cheerio from 'cheerio'; import * as cheerio from "cheerio";
export interface ExtractedContent { export interface ExtractedContent {
title?: string; title?: string;
@ -8,17 +8,21 @@ export interface ExtractedContent {
error?: string; error?: string;
} }
export async function extractArticleContent(url: string): Promise<ExtractedContent> { export async function extractArticleContent(
url: string,
): Promise<ExtractedContent> {
try { try {
// Fetch the HTML content // Fetch the HTML content
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', "User-Agent":
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
'Accept-Language': 'ja,en-US;q=0.7,en;q=0.3', Accept:
'Accept-Encoding': 'gzip, deflate', "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
'Connection': 'keep-alive', "Accept-Language": "ja,en-US;q=0.7,en;q=0.3",
'Upgrade-Insecure-Requests': '1', "Accept-Encoding": "gzip, deflate",
Connection: "keep-alive",
"Upgrade-Insecure-Requests": "1",
}, },
signal: AbortSignal.timeout(30000), // 30 second timeout signal: AbortSignal.timeout(30000), // 30 second timeout
}); });
@ -31,52 +35,56 @@ export async function extractArticleContent(url: string): Promise<ExtractedConte
const $ = cheerio.load(html); const $ = cheerio.load(html);
// Remove unwanted elements // Remove unwanted elements
$('script, style, nav, header, footer, aside, .advertisement, .ads, .sidebar, .menu, .navigation, .social-share, .comments').remove(); $(
"script, style, nav, header, footer, aside, .advertisement, .ads, .sidebar, .menu, .navigation, .social-share, .comments",
).remove();
let content = ''; let content = "";
let title = ''; let title = "";
let description = ''; let description = "";
// Extract title // Extract title
title = $('title').text().trim() || title =
$('h1').first().text().trim() || $("title").text().trim() ||
$('meta[property="og:title"]').attr('content') || $("h1").first().text().trim() ||
''; $('meta[property="og:title"]').attr("content") ||
"";
// Extract description // Extract description
description = $('meta[name="description"]').attr('content') || description =
$('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr("content") ||
''; $('meta[property="og:description"]').attr("content") ||
"";
// Try multiple content extraction strategies // Try multiple content extraction strategies
const contentSelectors = [ const contentSelectors = [
// Common article selectors // Common article selectors
'article', "article",
'[role="main"]', '[role="main"]',
'.article-content', ".article-content",
'.post-content', ".post-content",
'.entry-content', ".entry-content",
'.content', ".content",
'.main-content', ".main-content",
'.article-body', ".article-body",
'.post-body', ".post-body",
'.story-body', ".story-body",
'.news-content', ".news-content",
// Japanese news site specific selectors // Japanese news site specific selectors
'.article', ".article",
'.news-article', ".news-article",
'.post', ".post",
'.entry', ".entry",
'#content', "#content",
'#main', "#main",
'.main', ".main",
// Fallback to common containers // Fallback to common containers
'.container', ".container",
'#container', "#container",
'main', "main",
'body' "body",
]; ];
for (const selector of contentSelectors) { for (const selector of contentSelectors) {
@ -87,8 +95,8 @@ export async function extractArticleContent(url: string): Promise<ExtractedConte
// Remove extra whitespace and normalize // Remove extra whitespace and normalize
extractedText = extractedText extractedText = extractedText
.replace(/\s+/g, ' ') .replace(/\s+/g, " ")
.replace(/\n\s*\n/g, '\n') .replace(/\n\s*\n/g, "\n")
.trim(); .trim();
// Only use if we found substantial content // Only use if we found substantial content
@ -101,50 +109,49 @@ export async function extractArticleContent(url: string): Promise<ExtractedConte
// If still no content, try paragraph extraction // If still no content, try paragraph extraction
if (!content) { if (!content) {
const paragraphs = $('p').map((_, el) => $(el).text().trim()).get(); const paragraphs = $("p")
.map((_, el) => $(el).text().trim())
.get();
content = paragraphs content = paragraphs
.filter(p => p.length > 50) // Filter out short paragraphs .filter((p) => p.length > 50) // Filter out short paragraphs
.join('\n\n'); .join("\n\n");
} }
// Final fallback: use body text // Final fallback: use body text
if (!content || content.length < 100) { if (!content || content.length < 100) {
content = $('body').text() content = $("body").text().replace(/\s+/g, " ").trim();
.replace(/\s+/g, ' ')
.trim();
} }
// Validate extracted content // Validate extracted content
if (!content || content.length < 50) { if (!content || content.length < 50) {
return { return {
title, title,
content: '', content: "",
description, description,
success: false, success: false,
error: 'Insufficient content extracted' error: "Insufficient content extracted",
}; };
} }
// Limit content length to avoid token limits // Limit content length to avoid token limits
const maxLength = 5000; const maxLength = 5000;
if (content.length > maxLength) { if (content.length > maxLength) {
content = content.substring(0, maxLength) + '...'; content = content.substring(0, maxLength) + "...";
} }
return { return {
title, title,
content, content,
description, description,
success: true success: true,
}; };
} catch (error) { } catch (error) {
return { return {
title: '', title: "",
content: '', content: "",
description: '', description: "",
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred' error: error instanceof Error ? error.message : "Unknown error occurred",
}; };
} }
} }
@ -153,14 +160,14 @@ export async function enhanceArticleContent(
originalTitle: string, originalTitle: string,
originalLink: string, originalLink: string,
originalContent?: string, originalContent?: string,
originalDescription?: string originalDescription?: string,
): Promise<{ content?: string; description?: string }> { ): Promise<{ content?: string; description?: string }> {
// If we already have substantial content, use it // If we already have substantial content, use it
const existingContent = originalContent || originalDescription || ''; const existingContent = originalContent || originalDescription || "";
if (existingContent.length > 500) { if (existingContent.length > 500) {
return { return {
content: originalContent, content: originalContent,
description: originalDescription description: originalDescription,
}; };
} }
@ -170,13 +177,13 @@ export async function enhanceArticleContent(
if (extracted.success && extracted.content) { if (extracted.success && extracted.content) {
return { return {
content: extracted.content, content: extracted.content,
description: extracted.description || originalDescription description: extracted.description || originalDescription,
}; };
} }
// Return original content if extraction failed // Return original content if extraction failed
return { return {
content: originalContent, content: originalContent,
description: originalDescription description: originalDescription,
}; };
} }

View File

@ -59,7 +59,12 @@ export async function openAI_ClassifyFeed(title: string): Promise<string> {
export async function openAI_GeneratePodcastContent( export async function openAI_GeneratePodcastContent(
title: string, title: string,
items: Array<{ title: string; link: string; content?: string; description?: string }>, items: Array<{
title: string;
link: string;
content?: string;
description?: string;
}>,
): Promise<string> { ): Promise<string> {
if (!title || title.trim() === "") { if (!title || title.trim() === "") {
throw new Error("Feed title is required for podcast content generation"); throw new Error("Feed title is required for podcast content generation");
@ -78,7 +83,8 @@ export async function openAI_GeneratePodcastContent(
} }
// Build detailed article information including content // Build detailed article information including content
const articleDetails = validItems.map((item, i) => { const articleDetails = validItems
.map((item, i) => {
let articleInfo = `${i + 1}. タイトル: ${item.title}\nURL: ${item.link}`; let articleInfo = `${i + 1}. タイトル: ${item.title}\nURL: ${item.link}`;
// Add content if available // Add content if available
@ -86,14 +92,16 @@ export async function openAI_GeneratePodcastContent(
if (content && content.trim()) { if (content && content.trim()) {
// Limit content length to avoid token limits // Limit content length to avoid token limits
const maxContentLength = 2000; const maxContentLength = 2000;
const truncatedContent = content.length > maxContentLength const truncatedContent =
content.length > maxContentLength
? content.substring(0, maxContentLength) + "..." ? content.substring(0, maxContentLength) + "..."
: content; : content;
articleInfo += `\n内容: ${truncatedContent}`; articleInfo += `\n内容: ${truncatedContent}`;
} }
return articleInfo; return articleInfo;
}).join("\n\n"); })
.join("\n\n");
const prompt = ` const prompt = `
あなたはプロのポッドキャスタです。以下に示すフィードタイトルに基づき、そのトピックに関する詳細なポッドキャスト原稿を作成してください。 あなたはプロのポッドキャスタです。以下に示すフィードタイトルに基づき、そのトピックに関する詳細なポッドキャスト原稿を作成してください。

View File

@ -44,7 +44,7 @@ function splitTextIntoChunks(text: string, maxLength: number = 50): string[] {
chunks.push(currentChunk.trim()); chunks.push(currentChunk.trim());
} }
return chunks.filter(chunk => chunk.length > 0); return chunks.filter((chunk) => chunk.length > 0);
} }
/** /**
@ -88,7 +88,7 @@ function splitLongSentence(sentence: string, maxLength: number): string[] {
} }
} }
return finalChunks.filter(chunk => chunk.length > 0); return finalChunks.filter((chunk) => chunk.length > 0);
} }
interface VoiceStyle { interface VoiceStyle {
@ -112,7 +112,9 @@ async function generateAudioForChunk(
const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`; const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`; const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
console.log(`チャンク${chunkIndex + 1}の音声クエリ開始: ${itemId} (${chunkText.length}文字)`); console.log(
`チャンク${chunkIndex + 1}の音声クエリ開始: ${itemId} (${chunkText.length}文字)`,
);
const queryResponse = await fetch(queryUrl, { const queryResponse = await fetch(queryUrl, {
method: "POST", method: "POST",
@ -157,10 +159,15 @@ async function generateAudioForChunk(
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
} }
const chunkWavPath = path.resolve(outputDir, `${itemId}_chunk_${chunkIndex}.wav`); const chunkWavPath = path.resolve(
outputDir,
`${itemId}_chunk_${chunkIndex}.wav`,
);
fs.writeFileSync(chunkWavPath, audioBuffer); fs.writeFileSync(chunkWavPath, audioBuffer);
console.log(`チャンク${chunkIndex + 1}のWAVファイル保存完了: ${chunkWavPath}`); console.log(
`チャンク${chunkIndex + 1}のWAVファイル保存完了: ${chunkWavPath}`,
);
return chunkWavPath; return chunkWavPath;
} }
@ -180,18 +187,27 @@ async function concatenateAudioFiles(
try { try {
// Write file list in FFmpeg concat format // Write file list in FFmpeg concat format
const fileList = wavFiles.map(file => `file '${path.resolve(file)}'`).join('\n'); const fileList = wavFiles
.map((file) => `file '${path.resolve(file)}'`)
.join("\n");
fs.writeFileSync(listFilePath, fileList); fs.writeFileSync(listFilePath, fileList);
console.log(`音声ファイル結合開始: ${wavFiles.length}個のファイルを結合 -> ${outputMp3Path}`); console.log(
`音声ファイル結合開始: ${wavFiles.length}個のファイルを結合 -> ${outputMp3Path}`,
);
const result = Bun.spawnSync([ const result = Bun.spawnSync([
ffmpegCmd, ffmpegCmd,
"-f", "concat", "-f",
"-safe", "0", "concat",
"-i", listFilePath, "-safe",
"-codec:a", "libmp3lame", "0",
"-qscale:a", "2", "-i",
listFilePath,
"-codec:a",
"libmp3lame",
"-qscale:a",
"2",
"-y", // Overwrite output file "-y", // Overwrite output file
outputMp3Path, outputMp3Path,
]); ]);
@ -236,7 +252,9 @@ export async function generateTTSWithoutQueue(
throw new Error("Script text is required for TTS generation"); throw new Error("Script text is required for TTS generation");
} }
console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}, ${scriptText.length}文字)`); console.log(
`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}, ${scriptText.length}文字)`,
);
// Split text into chunks // Split text into chunks
const chunks = splitTextIntoChunks(scriptText.trim()); const chunks = splitTextIntoChunks(scriptText.trim());
@ -259,7 +277,9 @@ export async function generateTTSWithoutQueue(
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]; const chunk = chunks[i];
if (!chunk) continue; if (!chunk) continue;
console.log(`チャンク${i + 1}/${chunks.length}処理中: "${chunk.substring(0, 30)}${chunk.length > 30 ? '...' : ''}"`); console.log(
`チャンク${i + 1}/${chunks.length}処理中: "${chunk.substring(0, 30)}${chunk.length > 30 ? "..." : ""}"`,
);
const wavPath = await generateAudioForChunk(chunk, i, itemId); const wavPath = await generateAudioForChunk(chunk, i, itemId);
generatedWavFiles.push(wavPath); generatedWavFiles.push(wavPath);
@ -276,9 +296,12 @@ export async function generateTTSWithoutQueue(
const result = Bun.spawnSync([ const result = Bun.spawnSync([
ffmpegCmd, ffmpegCmd,
"-i", firstWavFile, "-i",
"-codec:a", "libmp3lame", firstWavFile,
"-qscale:a", "2", "-codec:a",
"libmp3lame",
"-qscale:a",
"2",
"-y", "-y",
mp3FilePath, mp3FilePath,
]); ]);
@ -299,7 +322,6 @@ export async function generateTTSWithoutQueue(
console.log(`TTS生成完了: ${itemId} (${chunks.length}チャンク)`); console.log(`TTS生成完了: ${itemId} (${chunks.length}チャンク)`);
return path.basename(mp3FilePath); return path.basename(mp3FilePath);
} catch (error) { } catch (error) {
// Clean up any generated files on error // Clean up any generated files on error
for (const wavFile of generatedWavFiles) { for (const wavFile of generatedWavFiles) {
@ -322,7 +344,10 @@ export async function generateTTS(
try { try {
return await generateTTSWithoutQueue(itemId, scriptText, retryCount); return await generateTTSWithoutQueue(itemId, scriptText, retryCount);
} catch (error) { } catch (error) {
console.error(`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`, error); console.error(
`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`,
error,
);
if (retryCount < maxRetries) { if (retryCount < maxRetries) {
// Add to queue for retry only on initial failure // Add to queue for retry only on initial failure
@ -330,7 +355,9 @@ export async function generateTTS(
await addToQueue(itemId, scriptText, retryCount); await addToQueue(itemId, scriptText, retryCount);
throw new Error(`TTS generation failed, added to retry queue: ${error}`); throw new Error(`TTS generation failed, added to retry queue: ${error}`);
} else { } else {
throw new Error(`TTS generation failed after ${maxRetries + 1} attempts: ${error}`); throw new Error(
`TTS generation failed after ${maxRetries + 1} attempts: ${error}`,
);
} }
} }
} }