// portal-events.jsx — 告知ボード: DB(API)経由で催しの追加・削除・注目切替 + 画像アップロード + SNS共有 const API = "api.php"; const AUTH_API = "auth_api.php"; // API ヘルパ ─────────────────────────────────────────────── async function apiList() { const res = await fetch(API, { method: "GET", credentials: "same-origin" }); if (!res.ok) throw new Error("list failed"); const data = await res.json(); return data.events || []; } async function apiCreate(form, imageFile) { const fd = new FormData(); ["cat", "title", "date", "time", "place", "desc"].forEach(k => fd.append(k, form[k] || "")); if (imageFile) fd.append("image", imageFile); const res = await fetch(API, { method: "POST", body: fd, credentials: "same-origin" }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "create failed"); return data.event; } async function apiUpdate(id, patch) { const res = await fetch(API + "?id=" + id, { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify(patch) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "update failed"); return data.event; } async function apiDelete(id) { const res = await fetch(API + "?id=" + id, { method: "DELETE", credentials: "same-origin" }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "delete failed"); } return true; } // 認証API ───────────────────────────────────────────────── async function authStatus() { const res = await fetch(AUTH_API + "?action=status", { method: "GET", credentials: "same-origin" }); if (!res.ok) return { loggedIn: false }; return res.json(); } async function authLogin(loginId, password) { const res = await fetch(AUTH_API + "?action=login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ loginId, password }) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "login failed"); return data; } async function authLogout() { await fetch(AUTH_API + "?action=logout", { method: "POST", credentials: "same-origin" }); } // SNS シェア ─────────────────────────────────────────────── function shareEvent(platform, ev, toast) { const text = `【安佐町のお知らせ】${ev.title}\n📅 ${ev.date}${ev.time ? " / " + ev.time : ""}\n📍 ${ev.place}\n${ev.desc || ""}`; const shareUrl = location.href.split("#")[0] + "#events"; if (platform === "copy") { const full = text + "\n" + shareUrl + "\n#安佐町 #広島"; if (navigator.clipboard) { navigator.clipboard.writeText(full).then(() => toast("コピーしました ─ SNSに貼り付けてください")); } else { toast("コピーしました"); } return; } let url = ""; if (platform === "x") { url = "https://twitter.com/intent/tweet?text=" + encodeURIComponent(text) + "&url=" + encodeURIComponent(shareUrl) + "&hashtags=" + encodeURIComponent("安佐町,広島,ASACHO"); } else if (platform === "line") { url = "https://social-plugins.line.me/lineit/share?url=" + encodeURIComponent(shareUrl) + "&text=" + encodeURIComponent(text); } else if (platform === "fb") { url = "https://www.facebook.com/sharer/sharer.php?u=" + encodeURIComponent(shareUrl) + ""e=" + encodeURIComponent(text); } else if (platform === "ig") { const cap = text + "\n#安佐町 #広島 #ASACHO"; if (navigator.clipboard) navigator.clipboard.writeText(cap); toast("Instagram用キャプションをコピーしました"); return; } if (url) { window.open(url, "_blank", "noopener,width=600,height=640"); toast(platform.toUpperCase() + " のシェア画面を開きました"); } } // カードのビジュアル ── 画像があれば写真、なければ抽象シーン function EventVisual({ ev, onDelete, canEdit, onZoom }) { const c = catOf(ev.cat); const [imgOk, setImgOk] = React.useState(true); const showImg = ev.image && imgOk; // ev.image が変わったら読み込み状態をリセット React.useEffect(() => { setImgOk(true); }, [ev.image]); return (
{/* 画像が無い・読み込み失敗のときは常にこの背景を表示 */} {!showImg &&
{c.glyph}
} {showImg && ( {ev.title} onZoom(ev.image, ev.title)} onError={() => setImgOk(false)} style={{position:"absolute",inset:0,width:"100%",height:"100%",objectFit:"cover",cursor:"zoom-in",zIndex:1}} /> )}
{c.ja} · {c.en}
{canEdit && }
); } function EventCard({ ev, onDelete, onTogglePin, toast, canEdit, onZoom }) { const isNew = (Date.now() - (ev.createdAt || 0)) < 1000 * 60 * 60 * 24 * 14; const [expanded, setExpanded] = React.useState(false); const [overflowing, setOverflowing] = React.useState(false); const descRef = React.useRef(null); // 折りたたみ時に7行を超えているか判定(ボタンの表示要否) React.useEffect(() => { const el = descRef.current; if (!el) return; const check = () => { if (expanded) return; // 展開中は判定しない // scrollHeight が clientHeight より大きければ省略されている setOverflowing(el.scrollHeight - el.clientHeight > 2); }; check(); window.addEventListener("resize", check); return () => window.removeEventListener("resize", check); }, [ev.desc, expanded]); return (
{canEdit ? ( ) : ( ev.pinned && 注目 )} {isNew && !ev.pinned && NEW} {ev.date}{ev.time ? · {ev.time} : null}

{ev.title}

◉ {ev.place}

{ev.desc}

{(overflowing || expanded) && ( )}
SNSで広める
); } // 画像ライトボックス(拡大表示) function Lightbox({ src, alt, onClose }) { React.useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); }; if (src) { document.addEventListener("keydown", onKey); document.body.style.overflow = "hidden"; } return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, [src, onClose]); if (!src) return null; return (
e.stopPropagation()} style={{margin:0,maxWidth:"100%",maxHeight:"100%",display:"flex",flexDirection:"column", alignItems:"center",gap:16,cursor:"default"}}> {alt} {alt &&
{alt}
}
); } // ログイン画面(モーダル) function LoginModal({ open, onClose, onSuccess }) { const [loginId, setLoginId] = React.useState(""); const [password, setPassword] = React.useState(""); const [show, setShow] = React.useState(false); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(""); React.useEffect(() => { if (open) { setLoginId(""); setPassword(""); setShow(false); setBusy(false); setErr(""); } }, [open]); const submit = async () => { if (busy) return; if (!loginId.trim() || !password) { setErr("ログインIDとパスワードを入力してください"); return; } setBusy(true); setErr(""); try { const data = await authLogin(loginId.trim(), password); onSuccess(data); } catch (e) { setErr(e.message); setBusy(false); } }; return (
e.stopPropagation()}>
MEMBER LOGIN

ログイン

催しの追加・編集にはログインが必要です。

setLoginId(e.target.value)} onKeyDown={e => e.key === "Enter" && submit()} placeholder="ログインID" />
setPassword(e.target.value)} onKeyDown={e => e.key === "Enter" && submit()} placeholder="パスワード" style={{width:"100%",paddingRight:54}} />
{err &&
{err}
}
); } const BLANK = { cat: "notice", title: "", date: "", time: "", place: "", desc: "" }; function AddEventModal({ open, onClose, onAdd }) { const [form, setForm] = React.useState(BLANK); const [imageFile, setImageFile] = React.useState(null); const [preview, setPreview] = React.useState(""); const [busy, setBusy] = React.useState(false); const fileRef = React.useRef(null); React.useEffect(() => { if (open) { setForm(BLANK); setImageFile(null); setPreview(""); setBusy(false); } }, [open]); const set = (k, v) => setForm(f => ({ ...f, [k]: v })); const valid = form.title.trim() && form.date.trim() && form.place.trim(); const pickImage = (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; if (file.size > 15 * 1024 * 1024) { alert("画像サイズは15MB以下にしてください"); return; } setImageFile(file); const reader = new FileReader(); reader.onload = () => setPreview(reader.result); reader.readAsDataURL(file); }; const clearImage = () => { setImageFile(null); setPreview(""); if (fileRef.current) fileRef.current.value = ""; }; const submit = async () => { if (!valid || busy) return; setBusy(true); try { await onAdd(form, imageFile); onClose(); } catch (err) { alert("追加に失敗しました: " + err.message); setBusy(false); } }; return (
e.stopPropagation()}>
NEW ANNOUNCEMENT

催しを追加

{CATEGORIES.map(c => (
set("cat", c.id)}> {c.ja}
))}
set("title", e.target.value)} placeholder="例:親子でミニマルシェ" />
set("date", e.target.value)} placeholder="例:2026.06.28" />
set("time", e.target.value)} placeholder="例:10:00 — 14:00" />
set("place", e.target.value)} placeholder="例:飯室駅前ひろば" />