Update admin panel and server configuration

This commit is contained in:
2025-06-08 21:59:24 +09:00
parent b7f3ca6a27
commit 00ae265314
4 changed files with 584 additions and 79 deletions

View File

@ -64,10 +64,21 @@ interface Episode {
feedCategory?: string;
}
interface Setting {
key: string;
value: string | null;
isCredential: boolean;
description: string;
defaultValue: string;
required: boolean;
updatedAt: string;
}
function App() {
const [feeds, setFeeds] = useState<Feed[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [envVars, setEnvVars] = useState<EnvVars>({});
const [settings, setSettings] = useState<Setting[]>([]);
const [feedRequests, setFeedRequests] = useState<FeedRequest[]>([]);
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [loading, setLoading] = useState(true);
@ -80,8 +91,9 @@ function App() {
const [approvalNotes, setApprovalNotes] = useState<{ [key: string]: string }>(
{},
);
const [editingSettings, setEditingSettings] = useState<{ [key: string]: string }>({});
const [activeTab, setActiveTab] = useState<
"dashboard" | "feeds" | "episodes" | "env" | "batch" | "requests"
"dashboard" | "feeds" | "episodes" | "env" | "settings" | "batch" | "requests"
>("dashboard");
useEffect(() => {
@ -91,11 +103,12 @@ function App() {
const loadData = async () => {
setLoading(true);
try {
const [feedsRes, statsRes, envRes, requestsRes, episodesRes] =
const [feedsRes, statsRes, envRes, settingsRes, requestsRes, episodesRes] =
await Promise.all([
fetch("/api/admin/feeds"),
fetch("/api/admin/stats"),
fetch("/api/admin/env"),
fetch("/api/admin/settings"),
fetch("/api/admin/feed-requests"),
fetch("/api/admin/episodes"),
]);
@ -104,17 +117,19 @@ function App() {
!feedsRes.ok ||
!statsRes.ok ||
!envRes.ok ||
!settingsRes.ok ||
!requestsRes.ok ||
!episodesRes.ok
) {
throw new Error("Failed to load data");
}
const [feedsData, statsData, envData, requestsData, episodesData] =
const [feedsData, statsData, envData, settingsData, requestsData, episodesData] =
await Promise.all([
feedsRes.json(),
statsRes.json(),
envRes.json(),
settingsRes.json(),
requestsRes.json(),
episodesRes.json(),
]);
@ -122,6 +137,7 @@ function App() {
setFeeds(feedsData);
setStats(statsData);
setEnvVars(envData);
setSettings(settingsData);
setFeedRequests(requestsData);
setEpisodes(episodesData);
setError(null);
@ -338,6 +354,43 @@ function App() {
setApprovalNotes({ ...approvalNotes, [requestId]: notes });
};
const updateSetting = async (key: string, value: string) => {
try {
const res = await fetch(`/api/admin/settings/${key}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
const data = await res.json();
if (res.ok) {
setSuccess(data.message);
setEditingSettings({ ...editingSettings, [key]: "" });
loadData();
} else {
setError(data.error || "設定更新に失敗しました");
}
} catch (err) {
setError("設定更新に失敗しました");
console.error("Error updating setting:", err);
}
};
const startEditingSetting = (key: string, currentValue: string | null) => {
setEditingSettings({ ...editingSettings, [key]: currentValue || "" });
};
const cancelEditingSetting = (key: string) => {
const newEditing = { ...editingSettings };
delete newEditing[key];
setEditingSettings(newEditing);
};
const updateEditingValue = (key: string, value: string) => {
setEditingSettings({ ...editingSettings, [key]: value });
};
const deleteEpisode = async (episodeId: string, episodeTitle: string) => {
if (
!confirm(
@ -424,6 +477,12 @@ function App() {
>
</button>
<button
className={`btn ${activeTab === "settings" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("settings")}
>
</button>
<button
className={`btn ${activeTab === "env" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setActiveTab("env")}
@ -1087,6 +1146,172 @@ function App() {
</>
)}
{activeTab === "settings" && (
<>
<h3></h3>
<p style={{ marginBottom: "20px", color: "#7f8c8d" }}>
</p>
<div style={{ marginBottom: "20px" }}>
<div className="stats-grid" style={{ gridTemplateColumns: "repeat(4, 1fr)" }}>
<div className="stat-card">
<div className="value">{settings.length}</div>
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value">
{settings.filter(s => s.value !== null && s.value !== "").length}
</div>
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value">
{settings.filter(s => s.required).length}
</div>
<div className="label"></div>
</div>
<div className="stat-card">
<div className="value">
{settings.filter(s => s.isCredential).length}
</div>
<div className="label"></div>
</div>
</div>
</div>
<div className="settings-list">
{settings.map((setting) => (
<div key={setting.key} className="setting-item" style={{
display: "flex",
alignItems: "center",
padding: "16px",
border: "1px solid #ddd",
borderRadius: "4px",
marginBottom: "12px",
backgroundColor: setting.required && (!setting.value || setting.value === "") ? "#fff5f5" : "#fff"
}}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
<h4 style={{ margin: 0, fontSize: "16px" }}>{setting.key}</h4>
{setting.required && (
<span style={{
padding: "2px 6px",
background: "#dc3545",
color: "white",
borderRadius: "4px",
fontSize: "10px"
}}></span>
)}
{setting.isCredential && (
<span style={{
padding: "2px 6px",
background: "#ffc107",
color: "black",
borderRadius: "4px",
fontSize: "10px"
}}></span>
)}
</div>
<p style={{ margin: "0 0 8px 0", fontSize: "14px", color: "#666" }}>
{setting.description}
</p>
<div style={{ fontSize: "12px", color: "#999" }}>
: {setting.defaultValue || "なし"} |
: {new Date(setting.updatedAt).toLocaleString("ja-JP")}
</div>
{editingSettings[setting.key] !== undefined ? (
<div style={{ marginTop: "8px", display: "flex", gap: "8px", alignItems: "center" }}>
<input
type={setting.isCredential ? "password" : "text"}
value={editingSettings[setting.key]}
onChange={(e) => updateEditingValue(setting.key, e.target.value)}
placeholder={setting.defaultValue || "値を入力..."}
style={{
flex: 1,
padding: "6px 10px",
border: "1px solid #ddd",
borderRadius: "4px",
fontSize: "14px"
}}
/>
<button
className="btn btn-success"
onClick={() => updateSetting(setting.key, editingSettings[setting.key])}
style={{ fontSize: "12px", padding: "6px 12px" }}
>
</button>
<button
className="btn btn-secondary"
onClick={() => cancelEditingSetting(setting.key)}
style={{ fontSize: "12px", padding: "6px 12px" }}
>
</button>
</div>
) : (
<div style={{ marginTop: "8px", display: "flex", alignItems: "center", gap: "12px" }}>
<div style={{
flex: 1,
padding: "6px 10px",
background: "#f8f9fa",
border: "1px solid #e9ecef",
borderRadius: "4px",
fontSize: "14px",
minHeight: "32px",
display: "flex",
alignItems: "center"
}}>
{setting.value === null || setting.value === "" ? (
<span style={{ color: "#dc3545", fontStyle: "italic" }}></span>
) : setting.isCredential ? (
<span style={{ color: "#666" }}></span>
) : (
setting.value
)}
</div>
<button
className="btn btn-primary"
onClick={() => startEditingSetting(setting.key, setting.value)}
style={{ fontSize: "12px", padding: "6px 12px" }}
>
</button>
</div>
)}
</div>
</div>
))}
</div>
<div
style={{
marginTop: "20px",
padding: "16px",
background: "#f8f9fa",
borderRadius: "4px",
}}
>
<h4></h4>
<ul
style={{
fontSize: "14px",
color: "#6c757d",
marginTop: "8px",
paddingLeft: "20px",
}}
>
<li><strong>:</strong> </li>
<li><strong>:</strong> </li>
<li></li>
<li>使</li>
</ul>
</div>
</>
)}
{activeTab === "env" && (
<>
<h3></h3>

View File

@ -14,12 +14,16 @@ import {
fetchEpisodesWithArticles,
getAllCategories,
getAllFeedsIncludingInactive,
getAllSettings,
getFeedByUrl,
getFeedRequests,
getFeedsByCategory,
getFeedsGroupedByCategory,
getSetting,
getSettingsForAdminUI,
toggleFeedActive,
updateFeedRequestStatus,
updateSetting,
} from "./services/database.js";
// Validate configuration on startup
@ -92,6 +96,74 @@ app.get("/api/admin/env", async (c) => {
}
});
// Settings management API endpoints
app.get("/api/admin/settings", async (c) => {
try {
const settings = await getSettingsForAdminUI();
return c.json(settings);
} catch (error) {
console.error("Error fetching settings:", error);
return c.json({ error: "Failed to fetch settings" }, 500);
}
});
app.get("/api/admin/settings/:key", async (c) => {
try {
const key = c.req.param("key");
const setting = await getSetting(key);
if (!setting) {
return c.json({ error: "Setting not found" }, 404);
}
// Mask credential values for security
if (setting.isCredential && setting.value) {
setting.value = "••••••••";
}
return c.json(setting);
} catch (error) {
console.error("Error fetching setting:", error);
return c.json({ error: "Failed to fetch setting" }, 500);
}
});
app.put("/api/admin/settings/:key", async (c) => {
try {
const key = c.req.param("key");
const { value } = await c.req.json<{ value: string }>();
if (!value && value !== "") {
return c.json({ error: "Setting value is required" }, 400);
}
console.log(`🔧 Admin updating setting: ${key}`);
// Check if setting exists
const existingSetting = await getSetting(key);
if (!existingSetting) {
return c.json({ error: "Setting not found" }, 404);
}
const updated = await updateSetting(key, value);
if (updated) {
return c.json({
result: "UPDATED",
message: "Setting updated successfully",
key,
// Don't return the value for credentials
value: existingSetting.isCredential ? "••••••••" : value,
});
} else {
return c.json({ error: "Failed to update setting" }, 500);
}
} catch (error) {
console.error("Error updating setting:", error);
return c.json({ error: "Failed to update setting" }, 500);
}
});
// Feed management API endpoints
app.get("/api/admin/feeds", async (c) => {
try {

View File

@ -68,6 +68,17 @@ CREATE TABLE IF NOT EXISTS feed_requests (
admin_notes TEXT
);
-- Environment variable settings stored in database
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
is_credential BOOLEAN DEFAULT 0,
description TEXT,
default_value TEXT,
required BOOLEAN DEFAULT 0,
updated_at TEXT NOT NULL
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
@ -78,3 +89,4 @@ CREATE INDEX IF NOT EXISTS idx_tts_queue_status ON tts_queue(status);
CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at);
CREATE INDEX IF NOT EXISTS idx_feed_requests_status ON feed_requests(status);
CREATE INDEX IF NOT EXISTS idx_feed_requests_created_at ON feed_requests(created_at);
CREATE INDEX IF NOT EXISTS idx_settings_is_credential ON settings(is_credential);

View File

@ -126,86 +126,21 @@ function initializeDatabase(): Database {
db.exec("PRAGMA cache_size = 1000;");
db.exec("PRAGMA temp_store = memory;");
// Ensure schema is set up - use the complete schema
db.exec(`CREATE TABLE IF NOT EXISTS feeds (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
title TEXT,
description TEXT,
category TEXT,
last_updated TEXT,
created_at TEXT NOT NULL,
active BOOLEAN DEFAULT 1
);
CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
feed_id TEXT NOT NULL,
title TEXT NOT NULL,
link TEXT NOT NULL UNIQUE,
description TEXT,
content TEXT,
pub_date TEXT NOT NULL,
discovered_at TEXT NOT NULL,
processed BOOLEAN DEFAULT 0,
FOREIGN KEY(feed_id) REFERENCES feeds(id)
);
CREATE TABLE IF NOT EXISTS episodes (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
audio_path TEXT NOT NULL,
duration INTEGER,
file_size INTEGER,
category TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(article_id) REFERENCES articles(id)
);
CREATE TABLE IF NOT EXISTS processed_feed_items (
feed_url TEXT NOT NULL,
item_id TEXT NOT NULL,
processed_at TEXT NOT NULL,
PRIMARY KEY(feed_url, item_id)
);
CREATE TABLE IF NOT EXISTS tts_queue (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL,
script_text TEXT NOT NULL,
retry_count INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
last_attempted_at TEXT,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'failed'))
);
CREATE TABLE IF NOT EXISTS feed_requests (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
requested_by TEXT,
request_message TEXT,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
created_at TEXT NOT NULL,
reviewed_at TEXT,
reviewed_by TEXT,
admin_notes TEXT
);
CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed);
CREATE INDEX IF NOT EXISTS idx_episodes_article_id ON episodes(article_id);
CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active);
CREATE INDEX IF NOT EXISTS idx_tts_queue_status ON tts_queue(status);
CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at);
CREATE INDEX IF NOT EXISTS idx_feed_requests_status ON feed_requests(status);
CREATE INDEX IF NOT EXISTS idx_feed_requests_created_at ON feed_requests(created_at);`);
// Load and execute schema from file
const schemaPath = path.join(config.paths.projectRoot, "schema.sql");
if (fs.existsSync(schemaPath)) {
const schema = fs.readFileSync(schemaPath, "utf-8");
db.exec(schema);
} else {
throw new Error(`Schema file not found: ${schemaPath}`);
}
// Perform database integrity checks and fixes
performDatabaseIntegrityFixes(db);
// Initialize settings table with default values
initializeSettings();
// ALTER
// ALTER TABLE feeds ADD COLUMN category TEXT DEFAULT NULL;
// Ensure the category column exists in feeds
@ -1666,6 +1601,267 @@ export async function getEpisodeCategoryMigrationStatus(): Promise<{
}
}
// Settings management functions
export interface Setting {
key: string;
value: string | null;
isCredential: boolean;
description: string;
defaultValue: string;
required: boolean;
updatedAt: string;
}
export async function initializeSettings(): Promise<void> {
const defaultSettings: Omit<Setting, "updatedAt">[] = [
{
key: "OPENAI_API_KEY",
value: null,
isCredential: true,
description: "OpenAI API Key for content generation",
defaultValue: "",
required: true,
},
{
key: "OPENAI_API_ENDPOINT",
value: null,
isCredential: false,
description: "OpenAI API Endpoint URL",
defaultValue: "https://api.openai.com/v1",
required: false,
},
{
key: "OPENAI_MODEL_NAME",
value: null,
isCredential: false,
description: "OpenAI Model Name",
defaultValue: "gpt-4o-mini",
required: false,
},
{
key: "VOICEVOX_HOST",
value: null,
isCredential: false,
description: "VOICEVOX Server Host URL",
defaultValue: "http://localhost:50021",
required: false,
},
{
key: "VOICEVOX_STYLE_ID",
value: null,
isCredential: false,
description: "VOICEVOX Voice Style ID",
defaultValue: "0",
required: false,
},
{
key: "PODCAST_TITLE",
value: null,
isCredential: false,
description: "Podcast Title",
defaultValue: "自動生成ポッドキャスト",
required: false,
},
{
key: "PODCAST_LINK",
value: null,
isCredential: false,
description: "Podcast Link URL",
defaultValue: "https://your-domain.com/podcast",
required: false,
},
{
key: "PODCAST_DESCRIPTION",
value: null,
isCredential: false,
description: "Podcast Description",
defaultValue: "RSSフィードから自動生成された音声ポッドキャスト",
required: false,
},
{
key: "PODCAST_LANGUAGE",
value: null,
isCredential: false,
description: "Podcast Language",
defaultValue: "ja",
required: false,
},
{
key: "PODCAST_AUTHOR",
value: null,
isCredential: false,
description: "Podcast Author",
defaultValue: "管理者",
required: false,
},
{
key: "PODCAST_CATEGORIES",
value: null,
isCredential: false,
description: "Podcast Categories",
defaultValue: "Technology",
required: false,
},
{
key: "PODCAST_TTL",
value: null,
isCredential: false,
description: "Podcast TTL",
defaultValue: "60",
required: false,
},
{
key: "PODCAST_BASE_URL",
value: null,
isCredential: false,
description: "Podcast Base URL",
defaultValue: "https://your-domain.com",
required: false,
},
{
key: "ADMIN_PORT",
value: null,
isCredential: false,
description: "Admin Panel Port",
defaultValue: "3001",
required: false,
},
{
key: "ADMIN_USERNAME",
value: null,
isCredential: true,
description: "Admin Panel Username",
defaultValue: "",
required: false,
},
{
key: "ADMIN_PASSWORD",
value: null,
isCredential: true,
description: "Admin Panel Password",
defaultValue: "",
required: false,
},
{
key: "DISABLE_INITIAL_BATCH",
value: null,
isCredential: false,
description: "Disable Initial Batch Process",
defaultValue: "false",
required: false,
},
{
key: "FEED_URLS_FILE",
value: null,
isCredential: false,
description: "Feed URLs File Path",
defaultValue: "feed_urls.txt",
required: false,
},
];
const now = new Date().toISOString();
for (const setting of defaultSettings) {
try {
const stmt = db.prepare(
"INSERT OR IGNORE INTO settings (key, value, is_credential, description, default_value, required, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
);
stmt.run(
setting.key,
setting.value,
setting.isCredential ? 1 : 0,
setting.description,
setting.defaultValue,
setting.required ? 1 : 0,
now
);
} catch (error) {
console.error(`Error initializing setting ${setting.key}:`, error);
}
}
}
export async function getAllSettings(): Promise<Setting[]> {
try {
const stmt = db.prepare("SELECT * FROM settings ORDER BY key");
const rows = stmt.all() as any[];
return rows.map((row) => ({
key: row.key,
value: row.value,
isCredential: Boolean(row.is_credential),
description: row.description,
defaultValue: row.default_value,
required: Boolean(row.required),
updatedAt: row.updated_at,
}));
} catch (error) {
console.error("Error getting all settings:", error);
throw error;
}
}
export async function getSetting(key: string): Promise<Setting | null> {
try {
const stmt = db.prepare("SELECT * FROM settings WHERE key = ?");
const row = stmt.get(key) as any;
if (!row) return null;
return {
key: row.key,
value: row.value,
isCredential: Boolean(row.is_credential),
description: row.description,
defaultValue: row.default_value,
required: Boolean(row.required),
updatedAt: row.updated_at,
};
} catch (error) {
console.error(`Error getting setting ${key}:`, error);
throw error;
}
}
export async function updateSetting(key: string, value: string): Promise<boolean> {
try {
const now = new Date().toISOString();
const stmt = db.prepare(
"UPDATE settings SET value = ?, updated_at = ? WHERE key = ?"
);
const result = stmt.run(value, now, key);
return result.changes > 0;
} catch (error) {
console.error(`Error updating setting ${key}:`, error);
throw error;
}
}
export async function deleteSetting(key: string): Promise<boolean> {
try {
const stmt = db.prepare("DELETE FROM settings WHERE key = ?");
const result = stmt.run(key);
return result.changes > 0;
} catch (error) {
console.error(`Error deleting setting ${key}:`, error);
throw error;
}
}
export async function getSettingsForAdminUI(): Promise<Setting[]> {
try {
const settings = await getAllSettings();
return settings.map((setting) => ({
...setting,
// Mask credential values for security
value: setting.isCredential && setting.value ? "••••••••" : setting.value,
}));
} catch (error) {
console.error("Error getting settings for admin UI:", error);
throw error;
}
}
export function closeDatabase(): void {
db.close();
}