posimai-tech-events/index.html

1101 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="AIがイズを削ぎ落とした街の体温 - 地域イベント情報">
<title>Posimai Events</title>
<!-- PWA -->
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="apple-touch-icon" href="/logo.png">
<meta name="theme-color" content="#6EE7B7">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Events">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0a;
--surface: #111111;
--surface2: #1a1a1a;
--border: #252525;
--text: #e2e2e2;
--text2: #888;
--text3: #555;
--accent: #6EE7B7;
--accent-dim: rgba(110, 231, 183, 0.10);
--status-active: #6EE7B7;
--status-upcoming: #6EE7B7;
--status-ended: #4B5563;
--rose: #F87171;
--radius: 8px;
font-family: 'Inter', system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color-scheme: dark;
}
[data-theme="light"] {
--bg: #f7f8fa;
--surface: #ffffff;
--surface2: #f0f1f3;
--border: #e4e5e7;
--text: #1a1a1a;
--text2: #6b6b6b;
--text3: #a8a8a8;
--accent: #059669;
--accent-dim: rgba(5, 150, 105, 0.08);
--status-active: #059669;
--status-upcoming: #059669;
--status-ended: #9CA3AF;
color-scheme: light;
}
html, body { height: 100dvh; background: var(--bg); color: var(--text); }
body { display: flex; flex-direction: column; overflow: hidden; }
/* Header */
.header {
height: 52px;
min-height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--accent);
}
.logo i { width: 18px; height: 18px; }
.header-meta {
font-size: 12px;
color: var(--text3);
font-weight: 400;
margin-left: 4px;
}
.header-actions { display: flex; gap: 4px; }
.icon-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: var(--radius);
color: var(--text2);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.icon-btn:hover { color: var(--text); background: var(--surface2); }
.icon-btn i { width: 16px; height: 16px; }
/* Filter Tabs */
.filter-bar {
padding: 0 16px;
border-bottom: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
gap: 0;
flex-shrink: 0;
}
.filter-tab {
height: 40px;
padding: 0 14px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text2);
font-size: 13px;
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.filter-tab:hover { color: var(--text); }
.filter-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 16px;
padding: 0 4px;
margin-left: 5px;
background: var(--accent-dim);
color: var(--accent);
border-radius: 8px;
font-size: 10px;
font-weight: 600;
}
/* Content */
.content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.content-inner {
max-width: 720px;
margin: 0 auto;
padding: 16px 16px 80px;
}
/* Section Divider */
.section-label {
display: flex;
align-items: center;
gap: 8px;
margin: 20px 0 10px;
font-size: 11px;
font-weight: 600;
color: var(--text3);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.section-label:first-child { margin-top: 4px; }
.section-label::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
/* Event Card */
.event-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.1s;
}
.event-card:last-child { border-bottom: none; }
.event-card:hover { background: var(--accent-dim); margin: 0 -12px; padding: 14px 12px; border-radius: var(--radius); border-bottom-color: transparent; }
/* Date Badge */
.date-badge {
flex-shrink: 0;
width: 40px;
text-align: center;
padding-top: 2px;
}
.date-badge-day {
display: block;
font-size: 20px;
font-weight: 600;
line-height: 1;
color: var(--text);
}
.date-badge-sub {
display: block;
font-size: 10px;
color: var(--text3);
margin-top: 2px;
letter-spacing: 0.03em;
}
/* Event Body */
.event-body { flex: 1; min-width: 0; }
.event-tags {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.tag {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.03em;
}
.tag-active {
background: var(--accent-dim);
color: var(--status-active);
}
.tag-upcoming {
background: var(--accent-dim);
color: var(--status-upcoming);
opacity: 0.8;
}
.tag-ended {
background: rgba(75, 85, 99, 0.15);
color: var(--status-ended);
}
.tag-category {
background: var(--surface2);
color: var(--text3);
}
.event-title {
font-size: 14px;
font-weight: 500;
color: var(--text);
line-height: 1.4;
margin-bottom: 5px;
}
.event-card[data-status="ended"] .event-title {
color: var(--text3);
}
.event-location {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text2);
margin-bottom: 4px;
}
.event-location i { width: 12px; height: 12px; flex-shrink: 0; }
.event-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text3);
}
.event-time i { width: 11px; height: 11px; flex-shrink: 0; }
/* Card Actions */
.event-actions {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 2px;
}
.action-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 6px;
color: var(--text3);
cursor: pointer;
transition: color 0.2s, background 0.15s;
text-decoration: none;
}
.action-btn:hover { color: var(--text2); background: var(--surface2); }
.action-btn i { width: 14px; height: 14px; }
.action-btn.saved { color: var(--accent); }
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
text-align: center;
gap: 12px;
color: var(--text3);
}
.empty-state i { width: 32px; height: 32px; opacity: 0.4; }
.empty-state p { font-size: 13px; line-height: 1.6; }
/* Detail Sheet (Bottom Sheet) */
.sheet-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 100;
opacity: 0;
transition: opacity 0.25s;
}
.sheet-backdrop.open { display: block; opacity: 1; }
.detail-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--surface);
border-radius: 16px 16px 0 0;
border-top: 1px solid var(--border);
z-index: 101;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.34, 1.1, 0.64, 1);
max-height: 85dvh;
display: flex;
flex-direction: column;
}
.detail-sheet.open { transform: translateY(0); }
@media (min-width: 600px) {
.detail-sheet {
left: auto;
right: 16px;
bottom: 16px;
width: 420px;
max-height: 75dvh;
border-radius: 12px;
border: 1px solid var(--border);
box-shadow: 0 16px 48px rgba(0,0,0,0.4);
}
}
.sheet-handle {
width: 36px;
height: 4px;
background: var(--border);
border-radius: 2px;
margin: 10px auto 0;
flex-shrink: 0;
}
@media (min-width: 600px) { .sheet-handle { display: none; } }
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 10px;
flex-shrink: 0;
}
.sheet-status-row {
display: flex;
align-items: center;
gap: 6px;
}
.sheet-body {
flex: 1;
overflow-y: auto;
padding: 0 16px 24px;
}
.sheet-title {
font-size: 17px;
font-weight: 600;
color: var(--text);
line-height: 1.4;
margin-bottom: 14px;
}
.sheet-meta-row {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 13px;
color: var(--text2);
margin-bottom: 8px;
}
.sheet-meta-row i { width: 14px; height: 14px; flex-shrink: 0; margin-top: 1px; }
.sheet-desc {
font-size: 13px;
color: var(--text2);
line-height: 1.7;
margin: 14px 0;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.sheet-footer {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.btn-primary {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 38px;
background: var(--accent);
color: #0a0a0a;
font-weight: 600;
font-size: 13px;
font-family: inherit;
border: none;
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
transition: opacity 0.15s;
}
.btn-primary:hover { opacity: 0.85; }
.btn-primary i { width: 14px; height: 14px; }
.btn-secondary {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 38px;
background: var(--surface2);
color: var(--text);
font-weight: 500;
font-size: 13px;
font-family: inherit;
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
transition: background 0.15s;
}
.btn-secondary:hover { background: var(--border); }
.btn-secondary i { width: 14px; height: 14px; }
/* Toast */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(16px);
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 16px;
font-size: 13px;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
z-index: 200;
pointer-events: none;
white-space: nowrap;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast i { width: 14px; height: 14px; color: var(--accent); }
/* Loading */
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* Safe area */
.content { padding-bottom: env(safe-area-inset-bottom); }
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="logo">
<i data-lucide="map-pin"></i>
<span>Posimai Events</span>
<span class="header-meta" id="headerDate"></span>
</div>
<div class="header-actions">
<button class="icon-btn" id="refreshBtn" title="更新">
<i data-lucide="refresh-cw"></i>
</button>
<button class="icon-btn" id="themeBtn" title="テーマ切替">
<i data-lucide="sun"></i>
</button>
</div>
</header>
<!-- Filter Tabs -->
<nav class="filter-bar">
<button class="filter-tab active" data-filter="today">
今日 <span class="filter-count" id="count-today">0</span>
</button>
<button class="filter-tab" data-filter="week">
今週 <span class="filter-count" id="count-week">0</span>
</button>
<button class="filter-tab" data-filter="all">
すべて <span class="filter-count" id="count-all">0</span>
</button>
</nav>
<!-- Content -->
<main class="content">
<div class="content-inner" id="eventList">
<div class="loading-state"><div class="spinner"></div></div>
</div>
</main>
<!-- Detail Sheet Backdrop -->
<div class="sheet-backdrop" id="backdrop"></div>
<!-- Detail Sheet -->
<div class="detail-sheet" id="detailSheet">
<div class="sheet-handle"></div>
<div class="sheet-header">
<div class="sheet-status-row" id="sheetStatusRow"></div>
<button class="icon-btn" id="closeSheet"><i data-lucide="x"></i></button>
</div>
<div class="sheet-body">
<h2 class="sheet-title" id="sheetTitle"></h2>
<div id="sheetMeta"></div>
<p class="sheet-desc" id="sheetDesc"></p>
</div>
<div class="sheet-footer">
<button class="btn-secondary" id="sheetBrainBtn">
<i data-lucide="bookmark"></i> Brainに保存
</button>
<a class="btn-primary" id="sheetLinkBtn" href="#" target="_blank" rel="noopener">
<i data-lucide="external-link"></i> 詳細を見る
</a>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast">
<i data-lucide="check"></i>
<span id="toastMsg">Brainに保存しました</span>
</div>
<script>
// ---- データ定義 ----
// 今日 = 2026-03-03 (現在地時間で計算)
const MOCK_EVENTS = [
{
id: '1',
title: '春の産直マルシェ',
startDate: '2026-03-03', startTime: '09:00',
endDate: '2026-03-05', endTime: '16:00',
location: '市民広場 イベントスペース',
address: '○○市中央1-1',
description: '地元農家が丹精込めて育てた旬の野菜・果物が並ぶ春の産直市。パン工房や手作りスイーツのブースも出店。家族でゆっくり楽しめます。',
category: 'マルシェ',
url: 'https://example.com/marche',
source: '市役所公式サイト'
},
{
id: '2',
title: '朝の太極拳教室(無料体験)',
startDate: '2026-03-03', startTime: '07:00',
endDate: '2026-03-03', endTime: '08:30',
location: '中央公園 芝生広場',
address: '○○市中央公園',
description: '毎週火・木・土曜開催の太極拳サークルが無料体験会を開催。初心者・シニアの方歓迎。動きやすい服装でお越しください。',
category: '体験・スポーツ',
url: 'https://example.com/taichi',
source: '地域掲示板'
},
{
id: '3',
title: '防災訓練・地域説明会',
startDate: '2026-03-05', startTime: '10:00',
endDate: '2026-03-05', endTime: '12:00',
location: '○○公民館 大ホール',
address: '○○市西町2-5',
description: '年1回の地区防災訓練と、今年度の避難計画変更に関する説明会を同日開催します。参加無料。',
category: '地域・行政',
url: 'https://example.com/bousai',
source: '町内会回覧板'
},
{
id: '4',
title: '伝統工芸・陶芸ワークショップ',
startDate: '2026-03-06', startTime: '13:00',
endDate: '2026-03-06', endTime: '17:00',
location: '○○文化センター 工芸室',
address: '○○市文化通り3-8',
description: '地元陶芸家による手びねり体験。土から形を作り、釉薬を選んで焼き上げ後日お渡し。定員12名・要事前申込。参加費 2,500円。',
category: 'ワークショップ',
url: 'https://example.com/ceramics',
source: '文化センターHP'
},
{
id: '5',
title: '春のクラフトフェア 2026',
startDate: '2026-03-07', startTime: '10:00',
endDate: '2026-03-08', endTime: '17:00',
location: '○○公園 野外広場',
address: '○○市北公園',
description: '全国から集まるクラフト作家80組が出展。アクセサリー、革工芸、テキスタイル、木工など多彩なジャンル。フードトラックも10台出店。入場無料。',
category: 'マーケット',
url: 'https://example.com/craft',
source: '実行委員会HP'
},
{
id: '6',
title: '地域清掃ボランティア',
startDate: '2026-03-08', startTime: '09:00',
endDate: '2026-03-08', endTime: '11:00',
location: '○○川 河川敷',
address: '○○市河川敷公園',
description: '春の清掃活動。参加自由・事前申込不要。軍手・ゴミ袋は主催者が用意。終了後、軽食の提供あり。',
category: '地域・ボランティア',
url: 'https://example.com/cleanup',
source: '市環境課HP'
},
{
id: '7',
title: '春のクラシックコンサート',
startDate: '2026-03-14', startTime: '15:00',
endDate: '2026-03-14', endTime: '17:30',
location: '○○市民ホール 小ホール',
address: '○○市文化町1-1',
description: '地元弦楽四重奏団による春のコンサート。ハイドン・シューベルトを中心に演奏。全席自由・入場料 1,000円高校生以下無料。',
category: '音楽・アート',
url: 'https://example.com/concert',
source: '市民ホールHP'
},
{
id: '8',
title: 'まちなかマルシェ(来月)',
startDate: '2026-04-04', startTime: '10:00',
endDate: '2026-04-05', endTime: '16:00',
location: '商店街アーケード',
address: '○○市本町通り',
description: '毎月第1土日開催の定期マルシェ。4月は春のテーマで出店者募集中出店費無料。',
category: 'マルシェ',
url: 'https://example.com/monthly',
source: '商店街組合HP'
},
{
id: '9',
title: '子ども映画祭(無料上映)',
startDate: '2026-02-28', startTime: '10:00',
endDate: '2026-03-02', endTime: '17:00',
location: '中央図書館 多目的ホール',
address: '○○市図書館通り',
description: '国内外の子ども向けアニメ・映画の無料上映会。3日間で10作品上映。入場無料・予約不要。',
category: '映画・文化',
url: 'https://example.com/film',
source: '図書館HP'
},
{
id: '10',
title: '早春の野鳥観察会',
startDate: '2026-03-01', startTime: '07:00',
endDate: '2026-03-01', endTime: '09:30',
location: '○○自然公園 入口',
address: '○○市郊外 自然公園',
description: '自然観察指導員が案内するバードウォッチング入門ツアー。双眼鏡の貸し出しあり。申込不要。',
category: '自然・体験',
url: 'https://example.com/birds',
source: '環境教育センターHP'
}
];
// ---- ユーティリティ ----
function today() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function getStatus(ev) {
const t = today();
if (ev.endDate < t) return 'ended';
if (ev.startDate <= t && ev.endDate >= t) return 'active';
const next7 = new Date(); next7.setDate(next7.getDate() + 7);
const nxt = next7.toISOString().slice(0,10);
if (ev.startDate <= nxt) return 'upcoming';
return 'future';
}
function statusLabel(s) {
return { active: '開催中', upcoming: '近日開催', future: '予定', ended: '終了' }[s];
}
function statusTagClass(s) {
return { active: 'tag-active', upcoming: 'tag-upcoming', future: 'tag-category', ended: 'tag-ended' }[s];
}
function formatDateBadge(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
const days = ['日','月','火','水','木','金','土'];
return {
day: d.getDate(),
sub: `${d.getMonth()+1}/${d.getDate()} ${days[d.getDay()]}`
};
}
function formatDateRange(ev) {
if (ev.startDate === ev.endDate) {
return `${ev.startDate.replace(/-/g,'/')} ${ev.startTime} - ${ev.endTime}`;
}
return `${ev.startDate.replace(/-/g,'/')} ${ev.startTime}${ev.endDate.replace(/-/g,'/')} ${ev.endTime}`;
}
function escapeHTML(s) {
return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ---- フィルター・グループ ----
let currentFilter = 'today';
let allEvents = [];
let savedIds = new Set(JSON.parse(localStorage.getItem('events-saved') || '[]'));
function filterEvents(events, filter) {
const t = today();
const next7 = new Date(); next7.setDate(next7.getDate() + 7);
const nxt = next7.toISOString().slice(0,10);
const weekStart = new Date(); weekStart.setDate(weekStart.getDate() - weekStart.getDay()); // 日曜
const weekEnd = new Date(weekStart); weekEnd.setDate(weekStart.getDate() + 6); // 土曜
const ws = weekStart.toISOString().slice(0,10);
const we = weekEnd.toISOString().slice(0,10);
if (filter === 'today') {
return events.filter(ev => ev.startDate <= t && ev.endDate >= t);
}
if (filter === 'week') {
return events.filter(ev => ev.startDate <= we && ev.endDate >= ws);
}
return events; // all
}
function groupEvents(events) {
const t = today();
const groups = {
active: { label: '開催中', events: [] },
upcoming: { label: '今週・近日', events: [] },
future: { label: '来月以降', events: [] },
ended: { label: '終了', events: [] }
};
for (const ev of events) {
const s = getStatus(ev);
groups[s].events.push(ev);
}
// 各グループを日付順ソート
for (const g of Object.values(groups)) {
g.events.sort((a,b) => (a.startDate+a.startTime).localeCompare(b.startDate+b.startTime));
}
return groups;
}
// ---- レンダリング ----
function renderCard(ev) {
const status = getStatus(ev);
const badge = formatDateBadge(ev.startDate);
const saved = savedIds.has(ev.id);
return `
<div class="event-card" data-status="${status}" data-id="${ev.id}" onclick="openDetail('${ev.id}')">
<div class="date-badge">
<span class="date-badge-day">${badge.day}</span>
<span class="date-badge-sub">${badge.sub}</span>
</div>
<div class="event-body">
<div class="event-tags">
<span class="tag ${statusTagClass(status)}">${statusLabel(status)}</span>
<span class="tag tag-category">${escapeHTML(ev.category)}</span>
</div>
<div class="event-title">${escapeHTML(ev.title)}</div>
<div class="event-location">
<i data-lucide="map-pin"></i>
<span>${escapeHTML(ev.location)}</span>
</div>
<div class="event-time">
<i data-lucide="clock"></i>
<span>${formatDateRange(ev)}</span>
</div>
</div>
<div class="event-actions" onclick="event.stopPropagation()">
<button class="action-btn ${saved?'saved':''}" title="Brainに保存"
onclick="saveToBrain('${ev.id}', event)">
<i data-lucide="${saved?'bookmark-check':'bookmark'}"></i>
</button>
<a class="action-btn" href="${escapeHTML(ev.url)}" target="_blank"
rel="noopener" title="詳細を見る">
<i data-lucide="external-link"></i>
</a>
</div>
</div>
`;
}
function renderTimeline() {
const filtered = filterEvents(allEvents, currentFilter);
const list = document.getElementById('eventList');
// カウント更新
document.getElementById('count-today').textContent = filterEvents(allEvents,'today').length;
document.getElementById('count-week').textContent = filterEvents(allEvents,'week').length;
document.getElementById('count-all').textContent = allEvents.length;
if (filtered.length === 0) {
list.innerHTML = `
<div class="empty-state">
<i data-lucide="calendar-x"></i>
<p>この期間のイベントはありません。<br>「すべて」タブでまとめて確認できます。</p>
</div>`;
lucide.createIcons();
return;
}
if (currentFilter === 'all') {
const groups = groupEvents(filtered);
let html = '';
const order = ['active', 'upcoming', 'future', 'ended'];
for (const key of order) {
const g = groups[key];
if (g.events.length === 0) continue;
html += `<div class="section-label">${g.label}</div>`;
html += g.events.map(renderCard).join('');
}
list.innerHTML = html;
} else {
const sorted = [...filtered].sort((a,b) =>
(a.startDate+a.startTime).localeCompare(b.startDate+b.startTime));
list.innerHTML = sorted.map(renderCard).join('');
}
lucide.createIcons();
}
// ---- データ読み込み ----
async function loadEvents() {
document.getElementById('eventList').innerHTML =
'<div class="loading-state"><div class="spinner"></div></div>';
try {
const res = await fetch('/api/events');
if (!res.ok) throw new Error('API error');
const data = await res.json();
allEvents = data.events || [];
// オフライン用にキャッシュ
localStorage.setItem('events-cache', JSON.stringify(allEvents));
} catch (e) {
// キャッシュから復元
const cached = localStorage.getItem('events-cache');
if (cached) {
allEvents = JSON.parse(cached);
} else {
allEvents = MOCK_EVENTS;
}
}
renderTimeline();
}
// ---- Brain 保存 ----
function saveToBrain(id, e) {
e.stopPropagation();
const ev = allEvents.find(x => x.id === id);
if (!ev) return;
savedIds.add(id);
localStorage.setItem('events-saved', JSON.stringify([...savedIds]));
// Brain アプリへ URL を渡す
const brainUrl = `https://posimai-brain.vercel.app/?url=${encodeURIComponent(ev.url)}&title=${encodeURIComponent(ev.title)}`;
window.open(brainUrl, '_blank');
showToast('Brainに保存しました');
renderTimeline();
}
// ---- Detail Sheet ----
let currentDetailId = null;
window.openDetail = function(id) {
const ev = allEvents.find(x => x.id === id);
if (!ev) return;
currentDetailId = id;
const status = getStatus(ev);
const saved = savedIds.has(id);
document.getElementById('sheetStatusRow').innerHTML = `
<span class="tag ${statusTagClass(status)}">${statusLabel(status)}</span>
<span class="tag tag-category">${escapeHTML(ev.category)}</span>
`;
document.getElementById('sheetTitle').textContent = ev.title;
document.getElementById('sheetMeta').innerHTML = `
<div class="sheet-meta-row">
<i data-lucide="calendar"></i>
<span>${formatDateRange(ev)}</span>
</div>
<div class="sheet-meta-row">
<i data-lucide="map-pin"></i>
<span>
<a href="https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(ev.location+' '+ev.address)}"
target="_blank" rel="noopener"
style="color:var(--accent);text-decoration:none;">
${escapeHTML(ev.location)}
</a>
</span>
</div>
<div class="sheet-meta-row">
<i data-lucide="info"></i>
<span style="color:var(--text3);font-size:12px;">情報元: ${escapeHTML(ev.source)}</span>
</div>
`;
document.getElementById('sheetDesc').textContent = ev.description;
document.getElementById('sheetLinkBtn').href = ev.url;
const brainBtn = document.getElementById('sheetBrainBtn');
brainBtn.innerHTML = saved
? '<i data-lucide="bookmark-check"></i> 保存済み'
: '<i data-lucide="bookmark"></i> Brainに保存';
brainBtn.style.color = saved ? 'var(--accent)' : '';
brainBtn.onclick = () => {
savedIds.add(id);
localStorage.setItem('events-saved', JSON.stringify([...savedIds]));
const brainUrl = `https://posimai-brain.vercel.app/?url=${encodeURIComponent(ev.url)}&title=${encodeURIComponent(ev.title)}`;
window.open(brainUrl, '_blank');
showToast('Brainに保存しました');
brainBtn.innerHTML = '<i data-lucide="bookmark-check"></i> 保存済み';
brainBtn.style.color = 'var(--accent)';
lucide.createIcons();
renderTimeline();
};
document.getElementById('detailSheet').classList.add('open');
document.getElementById('backdrop').classList.add('open');
lucide.createIcons();
};
function closeDetail() {
document.getElementById('detailSheet').classList.remove('open');
document.getElementById('backdrop').classList.remove('open');
currentDetailId = null;
}
document.getElementById('closeSheet').addEventListener('click', closeDetail);
document.getElementById('backdrop').addEventListener('click', closeDetail);
// ---- Toast ----
let toastTimer = null;
function showToast(msg) {
const t = document.getElementById('toast');
document.getElementById('toastMsg').textContent = msg;
t.classList.add('show');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2500);
}
// ---- Filter Tabs ----
document.querySelectorAll('.filter-tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.dataset.filter;
renderTimeline();
});
});
// ---- テーマ ----
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
const icon = document.querySelector('#themeBtn i');
if (icon) {
icon.setAttribute('data-lucide', theme === 'light' ? 'moon' : 'sun');
lucide.createIcons();
}
}
document.getElementById('themeBtn').addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
localStorage.setItem('events-theme', next);
applyTheme(next);
});
// ---- ヘッダー日付 ----
function updateHeaderDate() {
const d = new Date();
const days = ['日','月','火','水','木','金','土'];
document.getElementById('headerDate').textContent =
`${d.getMonth()+1}/${d.getDate()}(${days[d.getDay()]})`;
}
// ---- リフレッシュ ----
document.getElementById('refreshBtn').addEventListener('click', () => {
const btn = document.getElementById('refreshBtn').querySelector('i');
btn.style.transition = 'transform 0.5s';
btn.style.transform = 'rotate(360deg)';
setTimeout(() => { btn.style.transform = ''; }, 500);
loadEvents().then(() => showToast('最新情報に更新しました'));
});
// ---- PWA Service Worker ----
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.catch(() => {});
});
}
// ---- 初期化 ----
const savedTheme = localStorage.getItem('events-theme') || 'dark';
applyTheme(savedTheme);
updateHeaderDate();
lucide.createIcons();
loadEvents();
</script>
</body>
</html>