// 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 && (

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}}
);
}
// ログイン画面(モーダル)
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()}>
催しの追加・編集にはログインが必要です。
setLoginId(e.target.value)}
onKeyDown={e => e.key === "Enter" && submit()} placeholder="ログインID" />
{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()}>
{CATEGORIES.map(c => (
set("cat", c.id)}>
{c.ja}
))}
set("title", e.target.value)} placeholder="例:親子でミニマルシェ" />
set("place", e.target.value)} placeholder="例:飯室駅前ひろば" />
{/* 画像アップロード */}
);
}
function EventsSection({ toast }) {
const [events, setEvents] = React.useState([]);
const [filter, setFilter] = React.useState("all");
const [modal, setModal] = React.useState(false);
const [loginOpen, setLoginOpen] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState("");
const [admin, setAdmin] = React.useState(null); // ログイン中のID(未ログインなら null)
const [role, setRole] = React.useState(null); // admin / editor / poster
const [canManageMembers, setCanManageMembers] = React.useState(false);
const [zoom, setZoom] = React.useState(null); // { src, alt } | null
const loggedIn = !!admin; // ログインしていれば投稿は可能
const roleLabel = (r) => r === "admin" ? "管理者" : r === "editor" ? "編集者" : r === "poster" ? "投稿者" : "";
const refresh = React.useCallback(async () => {
try {
setLoading(true);
const list = await apiList();
setEvents(list);
setError("");
} catch (e) {
setError("催し物の読み込みに失敗しました。サーバーとデータベースの設定をご確認ください。");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
refresh();
authStatus().then(s => {
if (s.loggedIn) {
setAdmin(s.loginId || "user");
setRole(s.role || null);
setCanManageMembers(!!s.canManageMembers);
}
}).catch(() => {});
}, [refresh]);
// ログインが必要な操作を実行(未ログインならログイン画面へ)
const requireAuth = (fn) => {
if (loggedIn) { fn(); } else { setLoginOpen(true); }
};
const onLoginSuccess = (info) => {
setAdmin((info && info.loginId) || "user");
setRole((info && info.role) || null);
setCanManageMembers(!!(info && info.canManageMembers));
setLoginOpen(false);
toast("ログインしました");
};
const logout = async () => {
await authLogout();
setAdmin(null); setRole(null); setCanManageMembers(false);
toast("ログアウトしました");
};
const addEvent = async (form, imageFile) => {
const ev = await apiCreate(form, imageFile);
setEvents(list => [ev, ...list]);
toast("催しを掲載しました");
};
const delEvent = async (id) => {
if (!loggedIn) { setLoginOpen(true); return; }
if (!confirm("この催しを削除しますか?")) return;
try {
await apiDelete(id);
setEvents(list => list.filter(e => e.id !== id));
toast("削除しました");
} catch (e) {
if (/ログイン/.test(e.message)) { setAdmin(null); setLoginOpen(true); }
else alert("削除に失敗しました: " + e.message);
}
};
const togglePin = async (ev) => {
if (!loggedIn) { setLoginOpen(true); return; }
try {
const updated = await apiUpdate(ev.id, { pinned: !ev.pinned });
setEvents(list => list.map(e => e.id === updated.id ? updated : e));
} catch (e) {
if (/ログイン/.test(e.message)) { setAdmin(null); setLoginOpen(true); }
else alert("更新に失敗しました: " + e.message);
}
};
const counts = {};
events.forEach(e => { counts[e.cat] = (counts[e.cat] || 0) + 1; });
const shown = events
.filter(e => filter === "all" || e.cat === filter)
.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
return (
01 ─ ANNOUNCEMENTS
お知らせ・催しEvents & Notices
安佐町でひらかれる催しを掲載する掲示板。催しはデータベースに保存され、画像も掲載できます(画像はクリックで拡大表示)。各カードからX・FacebookなどSNSへ発信できます。催しの追加・編集にはログインが必要です(権限により編集範囲が異なります)。
{CATEGORIES.filter(c => counts[c.id]).map(c => (
))}
{loggedIn ? (
<>
{admin}({roleLabel(role)})
{canManageMembers && (
メンバー設定
)}
>
) : (
)}
{loading &&
読み込み中…
}
{!loading && error &&
{error}
}
{!loading && !error && shown.length === 0 && (
このカテゴリの催しはまだありません。{loggedIn && <>
「+ 催しを追加」から掲載できます。>}
)}
{!loading && !error && shown.map(ev => (
setZoom({ src, alt })} />
))}
FOLLOW US ─ 公式SNS
安佐町の「いま」を、
毎日お届けしています。
掲載された催しは公式アカウントでも発信中。フォローして、里山の四季と暮らしの情報を受け取ってください。
setModal(false)} onAdd={addEvent} />
setLoginOpen(false)} onSuccess={onLoginSuccess} />
setZoom(null)} />
);
}
Object.assign(window, { EventsSection });