First commit

This commit is contained in:
CyberRex
2026-05-23 17:03:05 +09:00
commit 40e7953ee5
52 changed files with 13004 additions and 0 deletions

View File

@@ -0,0 +1,313 @@
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Bell, Plus, Save, Trash2 } from 'lucide-react';
import { request } from '../api/client.js';
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
import { Field } from '../components/Field.jsx';
import { useToast } from '../components/Toast.jsx';
const unitToHours = {
hours: 1,
days: 24,
weeks: 168,
};
function toDisplayThreshold(thresholdHours) {
if (thresholdHours % unitToHours.weeks === 0) {
return { value: thresholdHours / unitToHours.weeks, unit: 'weeks' };
}
if (thresholdHours % unitToHours.days === 0) {
return { value: thresholdHours / unitToHours.days, unit: 'days' };
}
return { value: thresholdHours, unit: 'hours' };
}
function toThresholdHours(condition) {
return Number.parseInt(condition.value, 10) * unitToHours[condition.unit];
}
function formatCertificateValue(value) {
if (!value) return '未取得';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '未取得';
return date.toLocaleString();
}
export function SiteSettingsPanel({ site, onBack }) {
const [alias, setAlias] = useState(site.alias);
const [savedAlias, setSavedAlias] = useState(site.alias);
const [conditions, setConditions] = useState([{ value: 7, unit: 'days' }]);
const [availableWebhooks, setAvailableWebhooks] = useState([]);
const [webhookMethodIds, setWebhookMethodIds] = useState([]);
const [pushEnabled, setPushEnabled] = useState(false);
const [busy, setBusy] = useState(false);
const { showToast } = useToast();
const previewText = useMemo(() => {
const hours = conditions
.map(toThresholdHours)
.filter((value) => Number.isInteger(value) && value > 0)
.sort((a, b) => a - b);
if (hours.length === 0) return '通知タイミング未設定';
return hours.map((hour) => `${hour} 時間前`).join(' / ');
}, [conditions]);
useEffect(() => {
setAlias(site.alias);
setSavedAlias(site.alias);
}, [site.alias, site.siteId]);
useEffect(() => {
async function loadSettings() {
const data = await request(`/api/sites/${site.siteId}/settings`);
setAvailableWebhooks(data.settings.availableWebhooks);
if (data.settings.conditions.length > 0) {
setConditions(
data.settings.conditions.map((condition) => toDisplayThreshold(condition.thresholdHours)),
);
setWebhookMethodIds(data.settings.conditions[0].webhookMethodIds);
setPushEnabled(data.settings.conditions[0].pushEnabled);
}
}
loadSettings().catch((err) => showToast({ type: 'error', message: err.message }));
}, [showToast, site.siteId]);
function updateCondition(index, patch) {
setConditions((current) =>
current.map((condition, currentIndex) =>
currentIndex === index ? { ...condition, ...patch } : condition,
),
);
}
function addCondition() {
setConditions((current) => [...current, { value: 7, unit: 'days' }]);
}
function removeCondition(index) {
setConditions((current) => current.filter((_, currentIndex) => currentIndex !== index));
}
function toggleWebhook(methodId) {
setWebhookMethodIds((current) =>
current.includes(methodId) ? current.filter((id) => id !== methodId) : [...current, methodId],
);
}
async function saveSettings(event) {
event.preventDefault();
setBusy(true);
try {
const nextAlias = alias.trim();
if (!nextAlias) {
throw new Error('エイリアス名を入力してください');
}
if (nextAlias.length > 120) {
throw new Error('エイリアス名は120文字以内で入力してください');
}
const thresholdHours = conditions.map(toThresholdHours);
if (thresholdHours.some((value) => !Number.isInteger(value) || value <= 0 || value > 17520)) {
throw new Error('通知タイミングは 1 時間以上、2 年以内で指定してください');
}
if (nextAlias !== savedAlias) {
await request(`/api/sites/${site.siteId}`, {
method: 'PATCH',
body: JSON.stringify({ alias: nextAlias }),
});
setAlias(nextAlias);
setSavedAlias(nextAlias);
}
await request(`/api/sites/${site.siteId}/settings`, {
method: 'PUT',
body: JSON.stringify({
conditions: thresholdHours.map((value) => ({ thresholdHours: value })),
webhookMethodIds,
pushEnabled,
}),
});
showToast({ type: 'success', message: '設定を保存しました' });
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
async function deleteSettings() {
setBusy(true);
try {
await request(`/api/sites/${site.siteId}/settings`, { method: 'DELETE' });
setConditions([{ value: 7, unit: 'days' }]);
setWebhookMethodIds([]);
setPushEnabled(false);
showToast({ type: 'success', message: '設定を削除しました' });
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
return (
<main className="app-shell">
<header className="topbar">
<button className="back-button" onClick={onBack}>
<ArrowLeft aria-hidden="true" size={18} />
サイト一覧
</button>
<div className="settings-title">
<span className="eyebrow">サイト設定</span>
<h1>{savedAlias}</h1>
<p>{site.url}</p>
</div>
</header>
<section className="workspace settings-layout">
<form className="settings-form" onSubmit={saveSettings}>
<section className="panel">
<h2>基本情報</h2>
<Field label="エイリアス名">
<input
value={alias}
onChange={(event) => setAlias(event.target.value)}
maxLength="120"
required
/>
</Field>
</section>
<section className="panel">
<h2>証明書情報</h2>
<dl className="certificate-details">
<div>
<dt>発行元</dt>
<dd>{site.certificateIssuer || '未取得'}</dd>
</div>
<div>
<dt>発行日時</dt>
<dd>{formatCertificateValue(site.certificateIssuedAt)}</dd>
</div>
<div>
<dt>失効日時</dt>
<dd>{formatCertificateValue(site.certificateExpiresAt)}</dd>
</div>
</dl>
</section>
<section className="panel">
<div className="panel-heading">
<Bell aria-hidden="true" size={20} />
<div>
<h2>通知タイミング</h2>
<p>{previewText}</p>
</div>
</div>
<div className="condition-list">
{conditions.map((condition, index) => (
<div className="condition-row" key={index}>
<Field label="値">
<input
type="number"
min="1"
max="17520"
value={condition.value}
onChange={(event) => updateCondition(index, { value: event.target.value })}
required
/>
</Field>
<Field label="単位">
<select
value={condition.unit}
onChange={(event) => updateCondition(index, { unit: event.target.value })}
>
<option value="hours">時間前</option>
<option value="days">日前</option>
<option value="weeks">週間前</option>
</select>
</Field>
<ConfirmDialog
title="通知タイミングを削除"
description="この通知タイミングを設定から削除します。"
onConfirm={() => removeCondition(index)}
disabled={conditions.length === 1}
trigger={
<button
type="button"
className="icon-button danger"
disabled={conditions.length === 1}
aria-label="通知タイミングを削除"
title="削除"
>
<Trash2 aria-hidden="true" size={18} />
</button>
}
/>
</div>
))}
</div>
<button type="button" className="secondary" onClick={addCondition}>
<Plus aria-hidden="true" size={18} />
タイミングを追加
</button>
</section>
<section className="panel">
<h2>通知方法</h2>
<label className="check-row locked">
<input type="checkbox" checked readOnly />
アプリ内アラート
</label>
<label className="check-row">
<input
type="checkbox"
checked={pushEnabled}
onChange={(event) => setPushEnabled(event.target.checked)}
/>
プッシュ通知
</label>
<div className="webhook-box">
<h3>Webhook</h3>
{availableWebhooks.length === 0 ? (
<p className="muted">登録済みのWebhookはありません</p>
) : (
availableWebhooks.map((webhook) => (
<label className="check-row" key={webhook.notificationMethodId}>
<input
type="checkbox"
checked={webhookMethodIds.includes(webhook.notificationMethodId)}
onChange={() => toggleWebhook(webhook.notificationMethodId)}
/>
<span>
<strong>{webhook.alias}</strong>
<small>{webhook.url}</small>
</span>
</label>
))
)}
</div>
</section>
<div className="settings-actions">
<button className="primary" disabled={busy}>
<Save aria-hidden="true" size={18} />
保存
</button>
<ConfirmDialog
title="サイト設定を削除"
description="このサイトの通知タイミングと通知方法の設定を削除します。"
onConfirm={deleteSettings}
disabled={busy}
trigger={
<button type="button" className="secondary danger-text" disabled={busy}>
設定を削除
</button>
}
/>
</div>
</form>
</section>
</main>
);
}