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,275 @@
import { useEffect, useState } from 'react';
import { ArrowLeft, BellRing, Link, Pencil, Plus, 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';
function validateWebhookForm(form) {
if (!form.alias.trim()) {
throw new Error('エイリアス名を入力してください');
}
if (form.alias.trim().length > 120) {
throw new Error('エイリアス名は120文字以内で入力してください');
}
if (!form.url.trim()) {
throw new Error('Webhook URL を入力してください');
}
const url = new URL(form.url.trim());
if (url.protocol !== 'https:') {
throw new Error('Webhook URL は HTTPS で入力してください');
}
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = `${base64String}${padding}`.replaceAll('-', '+').replaceAll('_', '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
function permissionText(permission) {
if (permission === 'granted') return '許可済み';
if (permission === 'denied') return '拒否されています';
return '未設定';
}
export function NotificationMethodsView({ onBack }) {
const [webhooks, setWebhooks] = useState([]);
const [pushSubscriptions, setPushSubscriptions] = useState([]);
const [vapidPublicKey, setVapidPublicKey] = useState('');
const [form, setForm] = useState({ alias: '', url: '' });
const [editingId, setEditingId] = useState('');
const [permission, setPermission] = useState(
typeof Notification === 'undefined' ? 'unsupported' : Notification.permission,
);
const [busy, setBusy] = useState(false);
const { showToast } = useToast();
async function loadMethods() {
const data = await request('/api/notification-methods');
setWebhooks(data.webhooks);
setPushSubscriptions(data.pushSubscriptions);
setVapidPublicKey(data.vapidPublicKey);
}
useEffect(() => {
loadMethods().catch((err) => showToast({ type: 'error', message: err.message }));
}, [showToast]);
async function submitWebhook(event) {
event.preventDefault();
setBusy(true);
try {
validateWebhookForm(form);
const endpoint = editingId
? `/api/notification-methods/webhooks/${editingId}`
: '/api/notification-methods/webhooks';
await request(endpoint, {
method: editingId ? 'PATCH' : 'POST',
body: JSON.stringify({ alias: form.alias.trim(), url: form.url.trim() }),
});
setForm({ alias: '', url: '' });
setEditingId('');
showToast({
type: 'success',
message: editingId ? 'Webhookを更新しました' : 'Webhookを登録しました',
});
await loadMethods();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
function startEdit(webhook) {
setEditingId(webhook.notificationMethodId);
setForm({ alias: webhook.alias, url: webhook.url });
}
async function deleteWebhook(methodId) {
setBusy(true);
try {
await request(`/api/notification-methods/webhooks/${methodId}`, { method: 'DELETE' });
showToast({ type: 'success', message: 'Webhookを削除しました' });
await loadMethods();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
async function subscribePush() {
setBusy(true);
try {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('このブラウザはプッシュ通知に対応していません');
}
if (!vapidPublicKey) {
throw new Error('VAPID public key が設定されていません');
}
const nextPermission = await Notification.requestPermission();
setPermission(nextPermission);
if (nextPermission !== 'granted') {
throw new Error('ブラウザ通知が許可されませんでした');
}
const registration = await navigator.serviceWorker.register('/push-sw.js');
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
await request('/api/notification-methods/push-subscriptions', {
method: 'POST',
body: JSON.stringify(subscription.toJSON()),
});
showToast({ type: 'success', message: 'プッシュ通知を登録しました' });
await loadMethods();
} 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>通知方法の管理</h1>
</div>
</header>
<section className="workspace notification-layout">
<form className="panel" onSubmit={submitWebhook}>
<div className="panel-heading">
<Link aria-hidden="true" size={20} />
<div>
<h2>Webhook</h2>
<p>Slack互換Webhookとして送信します</p>
</div>
</div>
<div className="webhook-form">
<Field label="エイリアス名">
<input
value={form.alias}
onChange={(event) => setForm({ ...form, alias: event.target.value })}
placeholder="Slack 通知"
maxLength="120"
required
/>
</Field>
<Field label="URL">
<input
value={form.url}
onChange={(event) => setForm({ ...form, url: event.target.value })}
placeholder="https://hooks.slack.com/services/..."
maxLength="2048"
required
/>
</Field>
<button className="primary" disabled={busy}>
<Plus aria-hidden="true" size={18} />
{editingId ? '更新' : '登録'}
</button>
</div>
</form>
<section className="panel">
<h2>登録済みWebhook</h2>
<div className="method-list">
{webhooks.length === 0 ? (
<div className="empty">Webhookはまだ登録されていません</div>
) : (
webhooks.map((webhook) => (
<article className="method-row" key={webhook.notificationMethodId}>
<div>
<strong>{webhook.alias}</strong>
<span>{webhook.url}</span>
</div>
<div className="site-actions">
<button
className="icon-button"
type="button"
onClick={() => startEdit(webhook)}
aria-label={`${webhook.alias}を編集`}
title="編集"
>
<Pencil aria-hidden="true" size={18} />
</button>
<ConfirmDialog
title="Webhookを削除"
description={`${webhook.alias}を通知方法から削除します。`}
onConfirm={() => deleteWebhook(webhook.notificationMethodId)}
disabled={busy}
trigger={
<button
className="icon-button danger"
type="button"
aria-label={`${webhook.alias}を削除`}
title="削除"
>
<Trash2 aria-hidden="true" size={18} />
</button>
}
/>
</div>
</article>
))
)}
</div>
</section>
<section className="panel">
<div className="panel-heading">
<BellRing aria-hidden="true" size={20} />
<div>
<h2>プッシュ通知</h2>
<p>ブラウザ許可状態: {permissionText(permission)}</p>
</div>
</div>
<div className="push-actions">
<button
className="secondary"
type="button"
onClick={subscribePush}
disabled={busy || !vapidPublicKey}
>
<BellRing aria-hidden="true" size={18} />
このブラウザを登録
</button>
{!vapidPublicKey ? (
<p className="muted">VAPID public key を設定すると登録できます</p>
) : null}
</div>
<div className="method-list">
{pushSubscriptions.length === 0 ? (
<div className="empty">登録済みのブラウザはありません</div>
) : (
pushSubscriptions.map((subscription) => (
<article className="method-row" key={subscription.notificationMethodId}>
<div>
<strong>Browser Push</strong>
<span>{subscription.endpoint}</span>
</div>
</article>
))
)}
</div>
</section>
</section>
</main>
);
}