Some fixes
This commit is contained in:
@ -102,11 +102,26 @@ function App() {
|
|||||||
const [approvalNotes, setApprovalNotes] = useState<{ [key: string]: string }>(
|
const [approvalNotes, setApprovalNotes] = useState<{ [key: string]: string }>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const [editingSettings, setEditingSettings] = useState<{ [key: string]: string }>({});
|
const [editingSettings, setEditingSettings] = useState<{
|
||||||
const [categories, setCategories] = useState<CategoryData>({ feedCategories: [], episodeCategories: [], allCategories: [] });
|
[key: string]: string;
|
||||||
const [categoryCounts, setCategoryCounts] = useState<{ [category: string]: CategoryCounts }>({});
|
}>({});
|
||||||
|
const [categories, setCategories] = useState<CategoryData>({
|
||||||
|
feedCategories: [],
|
||||||
|
episodeCategories: [],
|
||||||
|
allCategories: [],
|
||||||
|
});
|
||||||
|
const [categoryCounts, setCategoryCounts] = useState<{
|
||||||
|
[category: string]: CategoryCounts;
|
||||||
|
}>({});
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"dashboard" | "feeds" | "episodes" | "env" | "settings" | "batch" | "requests" | "categories"
|
| "dashboard"
|
||||||
|
| "feeds"
|
||||||
|
| "episodes"
|
||||||
|
| "env"
|
||||||
|
| "settings"
|
||||||
|
| "batch"
|
||||||
|
| "requests"
|
||||||
|
| "categories"
|
||||||
>("dashboard");
|
>("dashboard");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -116,16 +131,23 @@ function App() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [feedsRes, statsRes, envRes, settingsRes, requestsRes, episodesRes, categoriesRes] =
|
const [
|
||||||
await Promise.all([
|
feedsRes,
|
||||||
fetch("/api/admin/feeds"),
|
statsRes,
|
||||||
fetch("/api/admin/stats"),
|
envRes,
|
||||||
fetch("/api/admin/env"),
|
settingsRes,
|
||||||
fetch("/api/admin/settings"),
|
requestsRes,
|
||||||
fetch("/api/admin/feed-requests"),
|
episodesRes,
|
||||||
fetch("/api/admin/episodes"),
|
categoriesRes,
|
||||||
fetch("/api/admin/categories/all"),
|
] = 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"),
|
||||||
|
fetch("/api/admin/categories/all"),
|
||||||
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!feedsRes.ok ||
|
!feedsRes.ok ||
|
||||||
@ -139,16 +161,23 @@ function App() {
|
|||||||
throw new Error("Failed to load data");
|
throw new Error("Failed to load data");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [feedsData, statsData, envData, settingsData, requestsData, episodesData, categoriesData] =
|
const [
|
||||||
await Promise.all([
|
feedsData,
|
||||||
feedsRes.json(),
|
statsData,
|
||||||
statsRes.json(),
|
envData,
|
||||||
envRes.json(),
|
settingsData,
|
||||||
settingsRes.json(),
|
requestsData,
|
||||||
requestsRes.json(),
|
episodesData,
|
||||||
episodesRes.json(),
|
categoriesData,
|
||||||
categoriesRes.json(),
|
] = await Promise.all([
|
||||||
]);
|
feedsRes.json(),
|
||||||
|
statsRes.json(),
|
||||||
|
envRes.json(),
|
||||||
|
settingsRes.json(),
|
||||||
|
requestsRes.json(),
|
||||||
|
episodesRes.json(),
|
||||||
|
categoriesRes.json(),
|
||||||
|
]);
|
||||||
|
|
||||||
setFeeds(feedsData);
|
setFeeds(feedsData);
|
||||||
setStats(statsData);
|
setStats(statsData);
|
||||||
@ -159,14 +188,18 @@ function App() {
|
|||||||
setCategories(categoriesData);
|
setCategories(categoriesData);
|
||||||
|
|
||||||
// Load category counts for all categories
|
// Load category counts for all categories
|
||||||
const countsPromises = categoriesData.allCategories.map(async (category: string) => {
|
const countsPromises = categoriesData.allCategories.map(
|
||||||
const res = await fetch(`/api/admin/categories/${encodeURIComponent(category)}/counts`);
|
async (category: string) => {
|
||||||
if (res.ok) {
|
const res = await fetch(
|
||||||
const counts = await res.json();
|
`/api/admin/categories/${encodeURIComponent(category)}/counts`,
|
||||||
return { category, counts };
|
);
|
||||||
}
|
if (res.ok) {
|
||||||
return { category, counts: { feedCount: 0, episodeCount: 0 } };
|
const counts = await res.json();
|
||||||
});
|
return { category, counts };
|
||||||
|
}
|
||||||
|
return { category, counts: { feedCount: 0, episodeCount: 0 } };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const countsResults = await Promise.all(countsPromises);
|
const countsResults = await Promise.all(countsPromises);
|
||||||
const countsMap: { [category: string]: CategoryCounts } = {};
|
const countsMap: { [category: string]: CategoryCounts } = {};
|
||||||
@ -426,26 +459,44 @@ function App() {
|
|||||||
setEditingSettings({ ...editingSettings, [key]: value });
|
setEditingSettings({ ...editingSettings, [key]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCategory = async (category: string, target: "feeds" | "episodes" | "both") => {
|
const deleteCategory = async (
|
||||||
const targetText = target === "both" ? "フィードとエピソード" : target === "feeds" ? "フィード" : "エピソード";
|
category: string,
|
||||||
const counts = categoryCounts[category] || { feedCount: 0, episodeCount: 0 };
|
target: "feeds" | "episodes" | "both",
|
||||||
const totalCount = target === "both" ? counts.feedCount + counts.episodeCount :
|
) => {
|
||||||
target === "feeds" ? counts.feedCount : counts.episodeCount;
|
const targetText =
|
||||||
|
target === "both"
|
||||||
|
? "フィードとエピソード"
|
||||||
|
: target === "feeds"
|
||||||
|
? "フィード"
|
||||||
|
: "エピソード";
|
||||||
|
const counts = categoryCounts[category] || {
|
||||||
|
feedCount: 0,
|
||||||
|
episodeCount: 0,
|
||||||
|
};
|
||||||
|
const totalCount =
|
||||||
|
target === "both"
|
||||||
|
? counts.feedCount + counts.episodeCount
|
||||||
|
: target === "feeds"
|
||||||
|
? counts.feedCount
|
||||||
|
: counts.episodeCount;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
`本当にカテゴリ「${category}」を${targetText}から削除しますか?\n\n${totalCount}件のアイテムが影響を受けます。この操作は取り消せません。`
|
`本当にカテゴリ「${category}」を${targetText}から削除しますか?\n\n${totalCount}件のアイテムが影響を受けます。この操作は取り消せません。`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/categories/${encodeURIComponent(category)}`, {
|
const res = await fetch(
|
||||||
method: "DELETE",
|
`/api/admin/categories/${encodeURIComponent(category)}`,
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({ target }),
|
method: "DELETE",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ target }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@ -1230,26 +1281,33 @@ function App() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ marginBottom: "20px" }}>
|
<div style={{ marginBottom: "20px" }}>
|
||||||
<div className="stats-grid" style={{ gridTemplateColumns: "repeat(4, 1fr)" }}>
|
<div
|
||||||
|
className="stats-grid"
|
||||||
|
style={{ gridTemplateColumns: "repeat(4, 1fr)" }}
|
||||||
|
>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value">{settings.length}</div>
|
<div className="value">{settings.length}</div>
|
||||||
<div className="label">総設定数</div>
|
<div className="label">総設定数</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value">
|
<div className="value">
|
||||||
{settings.filter(s => s.value !== null && s.value !== "").length}
|
{
|
||||||
|
settings.filter(
|
||||||
|
(s) => s.value !== null && s.value !== "",
|
||||||
|
).length
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className="label">設定済み</div>
|
<div className="label">設定済み</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value">
|
<div className="value">
|
||||||
{settings.filter(s => s.required).length}
|
{settings.filter((s) => s.required).length}
|
||||||
</div>
|
</div>
|
||||||
<div className="label">必須設定</div>
|
<div className="label">必須設定</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value">
|
<div className="value">
|
||||||
{settings.filter(s => s.isCredential).length}
|
{settings.filter((s) => s.isCredential).length}
|
||||||
</div>
|
</div>
|
||||||
<div className="label">認証情報</div>
|
<div className="label">認証情報</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1258,63 +1316,109 @@ function App() {
|
|||||||
|
|
||||||
<div className="settings-list">
|
<div className="settings-list">
|
||||||
{settings.map((setting) => (
|
{settings.map((setting) => (
|
||||||
<div key={setting.key} className="setting-item" style={{
|
<div
|
||||||
display: "flex",
|
key={setting.key}
|
||||||
alignItems: "center",
|
className="setting-item"
|
||||||
padding: "16px",
|
style={{
|
||||||
border: "1px solid #ddd",
|
display: "flex",
|
||||||
borderRadius: "4px",
|
alignItems: "center",
|
||||||
marginBottom: "12px",
|
padding: "16px",
|
||||||
backgroundColor: setting.required && (!setting.value || setting.value === "") ? "#fff5f5" : "#fff"
|
border: "1px solid #ddd",
|
||||||
}}>
|
borderRadius: "4px",
|
||||||
|
marginBottom: "12px",
|
||||||
|
backgroundColor:
|
||||||
|
setting.required &&
|
||||||
|
(!setting.value || setting.value === "")
|
||||||
|
? "#fff5f5"
|
||||||
|
: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
|
<div
|
||||||
<h4 style={{ margin: 0, fontSize: "16px" }}>{setting.key}</h4>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h4 style={{ margin: 0, fontSize: "16px" }}>
|
||||||
|
{setting.key}
|
||||||
|
</h4>
|
||||||
{setting.required && (
|
{setting.required && (
|
||||||
<span style={{
|
<span
|
||||||
padding: "2px 6px",
|
style={{
|
||||||
background: "#dc3545",
|
padding: "2px 6px",
|
||||||
color: "white",
|
background: "#dc3545",
|
||||||
borderRadius: "4px",
|
color: "white",
|
||||||
fontSize: "10px"
|
borderRadius: "4px",
|
||||||
}}>必須</span>
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
必須
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{setting.isCredential && (
|
{setting.isCredential && (
|
||||||
<span style={{
|
<span
|
||||||
padding: "2px 6px",
|
style={{
|
||||||
background: "#ffc107",
|
padding: "2px 6px",
|
||||||
color: "black",
|
background: "#ffc107",
|
||||||
borderRadius: "4px",
|
color: "black",
|
||||||
fontSize: "10px"
|
borderRadius: "4px",
|
||||||
}}>認証情報</span>
|
fontSize: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
認証情報
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: "0 0 8px 0", fontSize: "14px", color: "#666" }}>
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{setting.description}
|
{setting.description}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ fontSize: "12px", color: "#999" }}>
|
<div style={{ fontSize: "12px", color: "#999" }}>
|
||||||
デフォルト値: {setting.defaultValue || "なし"} |
|
デフォルト値: {setting.defaultValue || "なし"} |
|
||||||
最終更新: {new Date(setting.updatedAt).toLocaleString("ja-JP")}
|
最終更新:{" "}
|
||||||
|
{new Date(setting.updatedAt).toLocaleString("ja-JP")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingSettings[setting.key] !== undefined ? (
|
{editingSettings[setting.key] !== undefined ? (
|
||||||
<div style={{ marginTop: "8px", display: "flex", gap: "8px", alignItems: "center" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "8px",
|
||||||
|
display: "flex",
|
||||||
|
gap: "8px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type={setting.isCredential ? "password" : "text"}
|
type={setting.isCredential ? "password" : "text"}
|
||||||
value={editingSettings[setting.key]}
|
value={editingSettings[setting.key]}
|
||||||
onChange={(e) => updateEditingValue(setting.key, e.target.value)}
|
onChange={(e) =>
|
||||||
|
updateEditingValue(setting.key, e.target.value)
|
||||||
|
}
|
||||||
placeholder={setting.defaultValue || "値を入力..."}
|
placeholder={setting.defaultValue || "値を入力..."}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: "6px 10px",
|
padding: "6px 10px",
|
||||||
border: "1px solid #ddd",
|
border: "1px solid #ddd",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
fontSize: "14px"
|
fontSize: "14px",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="btn btn-success"
|
className="btn btn-success"
|
||||||
onClick={() => updateSetting(setting.key, editingSettings[setting.key])}
|
onClick={() =>
|
||||||
|
updateSetting(
|
||||||
|
setting.key,
|
||||||
|
editingSettings[setting.key],
|
||||||
|
)
|
||||||
|
}
|
||||||
style={{ fontSize: "12px", padding: "6px 12px" }}
|
style={{ fontSize: "12px", padding: "6px 12px" }}
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
@ -1328,20 +1432,36 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ marginTop: "8px", display: "flex", alignItems: "center", gap: "12px" }}>
|
<div
|
||||||
<div style={{
|
style={{
|
||||||
flex: 1,
|
marginTop: "8px",
|
||||||
padding: "6px 10px",
|
|
||||||
background: "#f8f9fa",
|
|
||||||
border: "1px solid #e9ecef",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "14px",
|
|
||||||
minHeight: "32px",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center"
|
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 === "" ? (
|
{setting.value === null || setting.value === "" ? (
|
||||||
<span style={{ color: "#dc3545", fontStyle: "italic" }}>未設定</span>
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#dc3545",
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
未設定
|
||||||
|
</span>
|
||||||
) : setting.isCredential ? (
|
) : setting.isCredential ? (
|
||||||
<span style={{ color: "#666" }}>••••••••</span>
|
<span style={{ color: "#666" }}>••••••••</span>
|
||||||
) : (
|
) : (
|
||||||
@ -1350,7 +1470,9 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => startEditingSetting(setting.key, setting.value)}
|
onClick={() =>
|
||||||
|
startEditingSetting(setting.key, setting.value)
|
||||||
|
}
|
||||||
style={{ fontSize: "12px", padding: "6px 12px" }}
|
style={{ fontSize: "12px", padding: "6px 12px" }}
|
||||||
>
|
>
|
||||||
編集
|
編集
|
||||||
@ -1379,10 +1501,18 @@ function App() {
|
|||||||
paddingLeft: "20px",
|
paddingLeft: "20px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<li><strong>必須設定:</strong> アプリケーションの動作に必要な設定です</li>
|
<li>
|
||||||
<li><strong>認証情報:</strong> セキュリティ上重要な情報で、表示時にマスクされます</li>
|
<strong>必須設定:</strong>{" "}
|
||||||
|
アプリケーションの動作に必要な設定です
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>認証情報:</strong>{" "}
|
||||||
|
セキュリティ上重要な情報で、表示時にマスクされます
|
||||||
|
</li>
|
||||||
<li>設定の変更は即座にアプリケーションに反映されます</li>
|
<li>設定の変更は即座にアプリケーションに反映されます</li>
|
||||||
<li>デフォルト値が設定されている項目は、空にするとデフォルト値が使用されます</li>
|
<li>
|
||||||
|
デフォルト値が設定されている項目は、空にするとデフォルト値が使用されます
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -1396,17 +1526,26 @@ function App() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ marginBottom: "20px" }}>
|
<div style={{ marginBottom: "20px" }}>
|
||||||
<div className="stats-grid" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}>
|
<div
|
||||||
|
className="stats-grid"
|
||||||
|
style={{ gridTemplateColumns: "repeat(3, 1fr)" }}
|
||||||
|
>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value">{categories.allCategories.length}</div>
|
<div className="value">
|
||||||
|
{categories.allCategories.length}
|
||||||
|
</div>
|
||||||
<div className="label">総カテゴリ数</div>
|
<div className="label">総カテゴリ数</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value">{categories.feedCategories.length}</div>
|
<div className="value">
|
||||||
|
{categories.feedCategories.length}
|
||||||
|
</div>
|
||||||
<div className="label">フィードカテゴリ</div>
|
<div className="label">フィードカテゴリ</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="value">{categories.episodeCategories.length}</div>
|
<div className="value">
|
||||||
|
{categories.episodeCategories.length}
|
||||||
|
</div>
|
||||||
<div className="label">エピソードカテゴリ</div>
|
<div className="label">エピソードカテゴリ</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1425,15 +1564,26 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ marginTop: "24px" }}>
|
<div style={{ marginTop: "24px" }}>
|
||||||
<h4>カテゴリ一覧 ({categories.allCategories.length}件)</h4>
|
<h4>カテゴリ一覧 ({categories.allCategories.length}件)</h4>
|
||||||
<div style={{ marginBottom: "16px", fontSize: "14px", color: "#666" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "16px",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666",
|
||||||
|
}}
|
||||||
|
>
|
||||||
削除対象を選択してから削除ボタンをクリックしてください。
|
削除対象を選択してから削除ボタンをクリックしてください。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="category-list">
|
<div className="category-list">
|
||||||
{categories.allCategories.map((category) => {
|
{categories.allCategories.map((category) => {
|
||||||
const counts = categoryCounts[category] || { feedCount: 0, episodeCount: 0 };
|
const counts = categoryCounts[category] || {
|
||||||
const isInFeeds = categories.feedCategories.includes(category);
|
feedCount: 0,
|
||||||
const isInEpisodes = categories.episodeCategories.includes(category);
|
episodeCount: 0,
|
||||||
|
};
|
||||||
|
const isInFeeds =
|
||||||
|
categories.feedCategories.includes(category);
|
||||||
|
const isInEpisodes =
|
||||||
|
categories.episodeCategories.includes(category);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -1442,42 +1592,70 @@ function App() {
|
|||||||
style={{ marginBottom: "16px" }}
|
style={{ marginBottom: "16px" }}
|
||||||
>
|
>
|
||||||
<div className="feed-info">
|
<div className="feed-info">
|
||||||
<h4 style={{ margin: "0 0 8px 0", fontSize: "16px" }}>
|
<h4
|
||||||
|
style={{ margin: "0 0 8px 0", fontSize: "16px" }}
|
||||||
|
>
|
||||||
{category}
|
{category}
|
||||||
</h4>
|
</h4>
|
||||||
<div style={{ fontSize: "14px", color: "#666", marginBottom: "8px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span>フィード: {counts.feedCount}件</span>
|
<span>フィード: {counts.feedCount}件</span>
|
||||||
<span style={{ margin: "0 12px" }}>|</span>
|
<span style={{ margin: "0 12px" }}>|</span>
|
||||||
<span>エピソード: {counts.episodeCount}件</span>
|
<span>エピソード: {counts.episodeCount}件</span>
|
||||||
<span style={{ margin: "0 12px" }}>|</span>
|
<span style={{ margin: "0 12px" }}>|</span>
|
||||||
<span>合計: {counts.feedCount + counts.episodeCount}件</span>
|
<span>
|
||||||
|
合計: {counts.feedCount + counts.episodeCount}件
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "12px", color: "#999" }}>
|
<div style={{ fontSize: "12px", color: "#999" }}>
|
||||||
<span style={{
|
<span
|
||||||
padding: "2px 6px",
|
style={{
|
||||||
background: isInFeeds ? "#e3f2fd" : "#f5f5f5",
|
padding: "2px 6px",
|
||||||
color: isInFeeds ? "#1976d2" : "#999",
|
background: isInFeeds ? "#e3f2fd" : "#f5f5f5",
|
||||||
borderRadius: "4px",
|
color: isInFeeds ? "#1976d2" : "#999",
|
||||||
marginRight: "8px"
|
borderRadius: "4px",
|
||||||
}}>
|
marginRight: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
フィード: {isInFeeds ? "使用中" : "未使用"}
|
フィード: {isInFeeds ? "使用中" : "未使用"}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span
|
||||||
padding: "2px 6px",
|
style={{
|
||||||
background: isInEpisodes ? "#e8f5e8" : "#f5f5f5",
|
padding: "2px 6px",
|
||||||
color: isInEpisodes ? "#388e3c" : "#999",
|
background: isInEpisodes
|
||||||
borderRadius: "4px"
|
? "#e8f5e8"
|
||||||
}}>
|
: "#f5f5f5",
|
||||||
|
color: isInEpisodes ? "#388e3c" : "#999",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
エピソード: {isInEpisodes ? "使用中" : "未使用"}
|
エピソード: {isInEpisodes ? "使用中" : "未使用"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="feed-actions" style={{ flexDirection: "column", gap: "8px", minWidth: "160px" }}>
|
<div
|
||||||
|
className="feed-actions"
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
minWidth: "160px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isInFeeds && (
|
{isInFeeds && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-warning"
|
className="btn btn-warning"
|
||||||
onClick={() => deleteCategory(category, "feeds")}
|
onClick={() =>
|
||||||
style={{ fontSize: "12px", padding: "6px 12px" }}
|
deleteCategory(category, "feeds")
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
padding: "6px 12px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
フィードから削除
|
フィードから削除
|
||||||
</button>
|
</button>
|
||||||
@ -1485,8 +1663,13 @@ function App() {
|
|||||||
{isInEpisodes && (
|
{isInEpisodes && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-warning"
|
className="btn btn-warning"
|
||||||
onClick={() => deleteCategory(category, "episodes")}
|
onClick={() =>
|
||||||
style={{ fontSize: "12px", padding: "6px 12px" }}
|
deleteCategory(category, "episodes")
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
padding: "6px 12px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
エピソードから削除
|
エピソードから削除
|
||||||
</button>
|
</button>
|
||||||
@ -1495,7 +1678,10 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
onClick={() => deleteCategory(category, "both")}
|
onClick={() => deleteCategory(category, "both")}
|
||||||
style={{ fontSize: "12px", padding: "6px 12px" }}
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
padding: "6px 12px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
すべてから削除
|
すべてから削除
|
||||||
</button>
|
</button>
|
||||||
@ -1525,10 +1711,21 @@ function App() {
|
|||||||
paddingLeft: "20px",
|
paddingLeft: "20px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<li><strong>フィードから削除:</strong> フィードのカテゴリのみを削除します</li>
|
<li>
|
||||||
<li><strong>エピソードから削除:</strong> エピソードのカテゴリのみを削除します</li>
|
<strong>フィードから削除:</strong>{" "}
|
||||||
<li><strong>すべてから削除:</strong> フィードとエピソード両方からカテゴリを削除します</li>
|
フィードのカテゴリのみを削除します
|
||||||
<li>削除されたカテゴリは NULL に設定され、分類が解除されます</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>エピソードから削除:</strong>{" "}
|
||||||
|
エピソードのカテゴリのみを削除します
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>すべてから削除:</strong>{" "}
|
||||||
|
フィードとエピソード両方からカテゴリを削除します
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
削除されたカテゴリは NULL に設定され、分類が解除されます
|
||||||
|
</li>
|
||||||
<li>この操作は元に戻すことができません</li>
|
<li>この操作は元に戻すことができません</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,17 +8,17 @@ import { batchScheduler } from "./services/batch-scheduler.js";
|
|||||||
import { config } from "./services/config.js";
|
import { config } from "./services/config.js";
|
||||||
import { closeBrowser } from "./services/content-extractor.js";
|
import { closeBrowser } from "./services/content-extractor.js";
|
||||||
import {
|
import {
|
||||||
|
deleteCategoryFromBoth,
|
||||||
deleteEpisode,
|
deleteEpisode,
|
||||||
|
deleteEpisodeCategory,
|
||||||
deleteFeed,
|
deleteFeed,
|
||||||
|
deleteFeedCategory,
|
||||||
fetchAllEpisodes,
|
fetchAllEpisodes,
|
||||||
fetchEpisodesWithArticles,
|
fetchEpisodesWithArticles,
|
||||||
getAllCategories,
|
getAllCategories,
|
||||||
getAllFeedsIncludingInactive,
|
getAllFeedsIncludingInactive,
|
||||||
getAllUsedCategories,
|
getAllUsedCategories,
|
||||||
getCategoryCounts,
|
getCategoryCounts,
|
||||||
deleteCategoryFromBoth,
|
|
||||||
deleteFeedCategory,
|
|
||||||
deleteEpisodeCategory,
|
|
||||||
getFeedByUrl,
|
getFeedByUrl,
|
||||||
getFeedRequests,
|
getFeedRequests,
|
||||||
getFeedsByCategory,
|
getFeedsByCategory,
|
||||||
@ -375,14 +375,19 @@ app.get("/api/admin/categories/:category/counts", async (c) => {
|
|||||||
app.delete("/api/admin/categories/:category", async (c) => {
|
app.delete("/api/admin/categories/:category", async (c) => {
|
||||||
try {
|
try {
|
||||||
const category = decodeURIComponent(c.req.param("category"));
|
const category = decodeURIComponent(c.req.param("category"));
|
||||||
const { target } = await c.req.json<{ target: "feeds" | "episodes" | "both" }>();
|
const { target } = await c.req.json<{
|
||||||
|
target: "feeds" | "episodes" | "both";
|
||||||
|
}>();
|
||||||
|
|
||||||
if (!category || category.trim() === "") {
|
if (!category || category.trim() === "") {
|
||||||
return c.json({ error: "Category name is required" }, 400);
|
return c.json({ error: "Category name is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!target || !["feeds", "episodes", "both"].includes(target)) {
|
if (!target || !["feeds", "episodes", "both"].includes(target)) {
|
||||||
return c.json({ error: "Valid target (feeds, episodes, or both) is required" }, 400);
|
return c.json(
|
||||||
|
{ error: "Valid target (feeds, episodes, or both) is required" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🗑️ Admin deleting category "${category}" from ${target}`);
|
console.log(`🗑️ Admin deleting category "${category}" from ${target}`);
|
||||||
@ -396,7 +401,7 @@ app.delete("/api/admin/categories/:category", async (c) => {
|
|||||||
category,
|
category,
|
||||||
feedChanges: result.feedChanges,
|
feedChanges: result.feedChanges,
|
||||||
episodeChanges: result.episodeChanges,
|
episodeChanges: result.episodeChanges,
|
||||||
totalChanges: result.feedChanges + result.episodeChanges
|
totalChanges: result.feedChanges + result.episodeChanges,
|
||||||
});
|
});
|
||||||
} else if (target === "feeds") {
|
} else if (target === "feeds") {
|
||||||
const changes = await deleteFeedCategory(category);
|
const changes = await deleteFeedCategory(category);
|
||||||
@ -406,7 +411,7 @@ app.delete("/api/admin/categories/:category", async (c) => {
|
|||||||
category,
|
category,
|
||||||
feedChanges: changes,
|
feedChanges: changes,
|
||||||
episodeChanges: 0,
|
episodeChanges: 0,
|
||||||
totalChanges: changes
|
totalChanges: changes,
|
||||||
});
|
});
|
||||||
} else if (target === "episodes") {
|
} else if (target === "episodes") {
|
||||||
const changes = await deleteEpisodeCategory(category);
|
const changes = await deleteEpisodeCategory(category);
|
||||||
@ -416,7 +421,7 @@ app.delete("/api/admin/categories/:category", async (c) => {
|
|||||||
category,
|
category,
|
||||||
feedChanges: 0,
|
feedChanges: 0,
|
||||||
episodeChanges: changes,
|
episodeChanges: changes,
|
||||||
totalChanges: changes
|
totalChanges: changes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Link, Route, Routes, useLocation } from "react-router-dom";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import EpisodeDetail from "./components/EpisodeDetail";
|
import EpisodeDetail from "./components/EpisodeDetail";
|
||||||
import EpisodeList from "./components/EpisodeList";
|
import EpisodeList from "./components/EpisodeList";
|
||||||
import FeedDetail from "./components/FeedDetail";
|
import FeedDetail from "./components/FeedDetail";
|
||||||
@ -10,13 +10,16 @@ import RSSEndpoints from "./components/RSSEndpoints";
|
|||||||
function App() {
|
function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||||
const saved = localStorage.getItem('darkMode');
|
const saved = localStorage.getItem("darkMode");
|
||||||
return saved ? JSON.parse(saved) : false;
|
return saved ? JSON.parse(saved) : false;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light');
|
document.documentElement.setAttribute(
|
||||||
localStorage.setItem('darkMode', JSON.stringify(isDarkMode));
|
"data-theme",
|
||||||
|
isDarkMode ? "dark" : "light",
|
||||||
|
);
|
||||||
|
localStorage.setItem("darkMode", JSON.stringify(isDarkMode));
|
||||||
}, [isDarkMode]);
|
}, [isDarkMode]);
|
||||||
|
|
||||||
const toggleDarkMode = () => {
|
const toggleDarkMode = () => {
|
||||||
@ -46,7 +49,7 @@ function App() {
|
|||||||
onClick={toggleDarkMode}
|
onClick={toggleDarkMode}
|
||||||
aria-label="テーマを切り替え"
|
aria-label="テーマを切り替え"
|
||||||
>
|
>
|
||||||
{isDarkMode ? '☀️' : '🌙'}
|
{isDarkMode ? "☀️" : "🌙"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -90,7 +90,9 @@ function EpisodeList() {
|
|||||||
searchParams.append("category", selectedCategory);
|
searchParams.append("category", selectedCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/episodes-with-feed-info?${searchParams}`);
|
const response = await fetch(
|
||||||
|
`/api/episodes-with-feed-info?${searchParams}`,
|
||||||
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("データベースからの取得に失敗しました");
|
throw new Error("データベースからの取得に失敗しました");
|
||||||
}
|
}
|
||||||
@ -402,9 +404,7 @@ function EpisodeList() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
{isSearching && (
|
{isSearching && <span className="episode-meta-text">検索中...</span>}
|
||||||
<span className="episode-meta-text">検索中...</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -587,7 +587,9 @@ function EpisodeList() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
onClick={() =>
|
||||||
|
setCurrentPage(Math.min(totalPages, currentPage + 1))
|
||||||
|
}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
>
|
>
|
||||||
次へ
|
次へ
|
||||||
|
@ -222,7 +222,10 @@ function FeedDetail() {
|
|||||||
<strong>
|
<strong>
|
||||||
<Link
|
<Link
|
||||||
to={`/episode/${episode.id}`}
|
to={`/episode/${episode.id}`}
|
||||||
style={{ textDecoration: "none", color: "var(--accent-primary)" }}
|
style={{
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "var(--accent-primary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{episode.title}
|
{episode.title}
|
||||||
</Link>
|
</Link>
|
||||||
@ -242,7 +245,10 @@ function FeedDetail() {
|
|||||||
href={episode.articleLink}
|
href={episode.articleLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ fontSize: "12px", color: "var(--text-secondary)" }}
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
元記事を見る
|
元記事を見る
|
||||||
</a>
|
</a>
|
||||||
|
@ -64,7 +64,7 @@ function FeedList() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Handle paginated response
|
// Handle paginated response
|
||||||
if (data.feeds && typeof data.total !== 'undefined') {
|
if (data.feeds && typeof data.total !== "undefined") {
|
||||||
setFeeds(data.feeds);
|
setFeeds(data.feeds);
|
||||||
setFilteredFeeds(data.feeds);
|
setFilteredFeeds(data.feeds);
|
||||||
setTotalFeeds(data.total);
|
setTotalFeeds(data.total);
|
||||||
@ -96,7 +96,6 @@ function FeedList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString("ja-JP");
|
return new Date(dateString).toLocaleString("ja-JP");
|
||||||
};
|
};
|
||||||
@ -197,7 +196,11 @@ function FeedList() {
|
|||||||
<option value={45}>45件表示</option>
|
<option value={45}>45件表示</option>
|
||||||
<option value={60}>60件表示</option>
|
<option value={60}>60件表示</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" className="btn btn-secondary" onClick={fetchFeeds}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={fetchFeeds}
|
||||||
|
>
|
||||||
更新
|
更新
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -287,7 +290,7 @@ function FeedList() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={pageNum}
|
key={pageNum}
|
||||||
className={`btn ${currentPage === pageNum ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${currentPage === pageNum ? "btn-primary" : "btn-secondary"}`}
|
||||||
onClick={() => setCurrentPage(pageNum)}
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
|
24
server.ts
24
server.ts
@ -515,7 +515,9 @@ app.get("/api/feeds", async (c) => {
|
|||||||
|
|
||||||
// If pagination parameters are provided, use paginated endpoint
|
// If pagination parameters are provided, use paginated endpoint
|
||||||
if (page || limit) {
|
if (page || limit) {
|
||||||
const { fetchActiveFeedsPaginated } = await import("./services/database.js");
|
const { fetchActiveFeedsPaginated } = await import(
|
||||||
|
"./services/database.js"
|
||||||
|
);
|
||||||
const pageNum = page ? Number.parseInt(page, 10) : 1;
|
const pageNum = page ? Number.parseInt(page, 10) : 1;
|
||||||
const limitNum = limit ? Number.parseInt(limit, 10) : 10;
|
const limitNum = limit ? Number.parseInt(limit, 10) : 10;
|
||||||
|
|
||||||
@ -527,7 +529,11 @@ app.get("/api/feeds", async (c) => {
|
|||||||
return c.json({ error: "Invalid limit (must be between 1-100)" }, 400);
|
return c.json({ error: "Invalid limit (must be between 1-100)" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await fetchActiveFeedsPaginated(pageNum, limitNum, category || undefined);
|
const result = await fetchActiveFeedsPaginated(
|
||||||
|
pageNum,
|
||||||
|
limitNum,
|
||||||
|
category || undefined,
|
||||||
|
);
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
} else {
|
} else {
|
||||||
// Original behavior for backward compatibility
|
// Original behavior for backward compatibility
|
||||||
@ -633,7 +639,9 @@ app.get("/api/episodes-with-feed-info", async (c) => {
|
|||||||
|
|
||||||
// If pagination parameters are provided, use paginated endpoint
|
// If pagination parameters are provided, use paginated endpoint
|
||||||
if (page || limit) {
|
if (page || limit) {
|
||||||
const { fetchEpisodesWithFeedInfoPaginated } = await import("./services/database.js");
|
const { fetchEpisodesWithFeedInfoPaginated } = await import(
|
||||||
|
"./services/database.js"
|
||||||
|
);
|
||||||
const pageNum = page ? Number.parseInt(page, 10) : 1;
|
const pageNum = page ? Number.parseInt(page, 10) : 1;
|
||||||
const limitNum = limit ? Number.parseInt(limit, 10) : 20;
|
const limitNum = limit ? Number.parseInt(limit, 10) : 20;
|
||||||
|
|
||||||
@ -645,11 +653,17 @@ app.get("/api/episodes-with-feed-info", async (c) => {
|
|||||||
return c.json({ error: "Invalid limit (must be between 1-100)" }, 400);
|
return c.json({ error: "Invalid limit (must be between 1-100)" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await fetchEpisodesWithFeedInfoPaginated(pageNum, limitNum, category || undefined);
|
const result = await fetchEpisodesWithFeedInfoPaginated(
|
||||||
|
pageNum,
|
||||||
|
limitNum,
|
||||||
|
category || undefined,
|
||||||
|
);
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
} else {
|
} else {
|
||||||
// Original behavior for backward compatibility
|
// Original behavior for backward compatibility
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user