posimai-tech-events/index.html

1075 lines
46 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">
<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; }
.icon-btn.has-filter { color: var(--accent); }
/* Filter Tabs */
.filter-bar {
padding: 0 16px;
border-bottom: 1px solid var(--border);
background: var(--surface);
display: flex; align-items: center;
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;
}
/* Active Filter Chips */
.active-filters {
display: flex; gap: 6px; align-items: center;
padding: 8px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
overflow-x: auto; -webkit-overflow-scrolling: touch;
scrollbar-width: none; flex-shrink: 0;
}
.active-filters::-webkit-scrollbar { display: none; }
.active-filters:empty { display: none; }
.active-filters.hidden { display: none; }
.chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px; border-radius: 20px;
background: var(--accent-dim); border: 1px solid var(--accent);
color: var(--accent); font-size: 11px; font-weight: 500;
white-space: nowrap; cursor: pointer; flex-shrink: 0;
transition: background 0.15s;
}
.chip:hover { background: rgba(110,231,183,0.2); }
.chip i { width: 10px; height: 10px; }
/* 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); }
.tag-interest { background: rgba(129,140,248,0.12); color: #818CF8; }
.tag-audience { background: rgba(251,191,36,0.12); color: #D97706; }
.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; }
/* Bottom Sheet (shared) */
.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; }
.bottom-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;
}
.bottom-sheet.open { transform: translateY(0); }
@media (min-width: 600px) {
.bottom-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-title-text { font-size: 14px; font-weight: 600; color: var(--text); }
.sheet-body { flex: 1; overflow-y: auto; padding: 0 16px 24px; }
/* Preference Sheet specific */
.pref-section { margin-bottom: 20px; }
.pref-section-title {
font-size: 11px; font-weight: 600; color: var(--text3);
letter-spacing: 0.08em; text-transform: uppercase;
margin-bottom: 10px; display: flex; align-items: center; gap: 6px;
}
.pref-section-title i { width: 12px; height: 12px; }
.pref-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.pref-tag {
display: inline-flex; align-items: center; gap: 5px;
padding: 5px 12px; border-radius: 20px;
background: var(--surface2); border: 1px solid var(--border);
color: var(--text2); font-size: 12px; font-weight: 500;
cursor: pointer; transition: all 0.15s; user-select: none;
}
.pref-tag.selected {
background: var(--accent-dim); border-color: var(--accent); color: var(--accent);
}
.pref-tag i { width: 12px; height: 12px; }
.pref-footer {
display: flex; gap: 8px; padding: 12px 16px;
border-top: 1px solid var(--border); flex-shrink: 0;
}
/* Detail Sheet specific */
.sheet-status-row { display: flex; align-items: center; gap: 6px; }
.sheet-title-h2 {
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-interest-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 4px; }
.sheet-desc {
font-size: 13px; color: var(--text2); line-height: 1.7;
margin: 14px 0; padding-top: 14px; border-top: 1px solid var(--border);
}
/* Buttons */
.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); } }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.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="filterBtn" title="絞り込み">
<i data-lucide="sliders-horizontal"></i>
</button>
<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>
<!-- Active Filter Chips -->
<div class="active-filters hidden" id="activeFilters"></div>
<!-- Content -->
<main class="content">
<div class="content-inner" id="eventList">
<div class="loading-state"><div class="spinner"></div></div>
</div>
</main>
<!-- Shared Backdrop -->
<div class="sheet-backdrop" id="backdrop"></div>
<!-- Preference / Filter Sheet -->
<div class="bottom-sheet" id="prefSheet">
<div class="sheet-handle"></div>
<div class="sheet-header">
<span class="sheet-title-text">絞り込み・マイ設定</span>
<button class="icon-btn" id="closePref"><i data-lucide="x"></i></button>
</div>
<div class="sheet-body">
<!-- Interest Tags -->
<div class="pref-section">
<div class="pref-section-title">
<i data-lucide="heart"></i> 興味・ジャンル
</div>
<div class="pref-tags" id="interestTagsList"></div>
</div>
<!-- Audience Tags -->
<div class="pref-section">
<div class="pref-section-title">
<i data-lucide="users"></i> 対象者・シーン
</div>
<div class="pref-tags" id="audienceTagsList"></div>
</div>
<!-- Options -->
<div class="pref-section">
<div class="pref-section-title">
<i data-lucide="settings-2"></i> オプション
</div>
<div class="pref-tags" id="optionTagsList"></div>
</div>
</div>
<div class="pref-footer">
<button class="btn-secondary" id="clearPref">クリア</button>
<button class="btn-primary" id="applyPref">この条件で絞り込む</button>
</div>
</div>
<!-- Detail Sheet -->
<div class="bottom-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="closeDetail"><i data-lucide="x"></i></button>
</div>
<div class="sheet-body">
<h2 class="sheet-title-h2" id="sheetTitle"></h2>
<div id="sheetMeta"></div>
<div class="sheet-interest-tags" id="sheetTags"></div>
<p class="sheet-desc" id="sheetDesc"></p>
</div>
<div class="pref-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>
// ---- 定数 ----
const INTEREST_TAGS = [
{ id: 'sake', label: '日本酒', icon: 'wine' },
{ id: 'beer', label: 'クラフトビール', icon: 'beer' },
{ id: 'wine', label: 'ワイン・お酒', icon: 'glass-water' },
{ id: 'food', label: 'グルメ・食', icon: 'utensils' },
{ id: 'market', label: 'マルシェ・市', icon: 'shopping-bag' },
{ id: 'music', label: '音楽・ライブ', icon: 'music' },
{ id: 'art', label: 'アート・展示', icon: 'palette' },
{ id: 'craft', label: 'クラフト・手工芸', icon: 'scissors' },
{ id: 'outdoor', label: 'アウトドア・自然', icon: 'trees' },
{ id: 'sports', label: 'スポーツ・体験', icon: 'dumbbell' },
{ id: 'culture', label: '伝統・文化', icon: 'landmark' },
{ id: 'film', label: '映画・演劇', icon: 'film' },
{ id: 'learn', label: '学び・セミナー', icon: 'book-open' },
{ id: 'volunteer', label: 'ボランティア', icon: 'hand-heart' },
{ id: 'kids', label: '子ども向け', icon: 'baby' },
];
const AUDIENCE_TAGS = [
{ id: 'couple', label: 'カップル向け', icon: 'heart' },
{ id: 'family', label: 'ファミリー歓迎', icon: 'users' },
{ id: 'solo', label: 'ソロ歓迎', icon: 'user' },
{ id: 'senior', label: 'シニア向け', icon: 'accessibility' },
{ id: 'pet', label: 'ペット同伴OK', icon: 'paw-print' },
{ id: 'foreign', label: '英語対応あり', icon: 'globe' },
];
const OPTION_TAGS = [
{ id: 'free', label: '無料', icon: 'circle-dollar-sign' },
{ id: 'no-rsvp', label: '申込不要', icon: 'door-open' },
{ id: 'outdoor', label: '屋外', icon: 'sun' },
{ id: 'indoor', label: '屋内', icon: 'home' },
];
// ---- モックイベントデータinterestTags / audienceTags 付き)----
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: '市役所公式サイト',
interestTags: ['market', 'food'],
audienceTags: ['family', 'solo'],
isFree: true, noRsvp: true, isOutdoor: false
},
{
id: '2',
title: '日本酒蔵元開放デー ~春の一杯~',
startDate: '2026-03-03', startTime: '11:00',
endDate: '2026-03-03', endTime: '16:00',
location: '○○酒造 蔵内特設会場',
address: '○○市蔵町1-5',
description: '年に一度の蔵元開放。仕込み中の新酒の試飲と蔵見学が楽しめます。限定ラベルのお持ち帰り販売もあり。入場無料・試飲は有料500円〜。',
category: '日本酒',
url: 'https://example.com/sake',
source: '蔵元公式HP',
interestTags: ['sake', 'food', 'culture'],
audienceTags: ['couple', 'solo'],
isFree: false, noRsvp: true, isOutdoor: false
},
{
id: '3',
title: 'クラフトビール祭り in ○○公園',
startDate: '2026-03-07', startTime: '12:00',
endDate: '2026-03-08', endTime: '19:00',
location: '○○公園 野外広場',
address: '○○市北公園',
description: '国内外のブルワリーが20社集結。樽生の飲み比べや、フードトラック10台が出店。ライブパフォーマンスも開催予定。',
category: 'フェスティバル',
url: 'https://example.com/beer',
source: '実行委員会HP',
interestTags: ['beer', 'food', 'music'],
audienceTags: ['couple', 'solo'],
isFree: false, noRsvp: false, isOutdoor: true
},
{
id: '4',
title: '日本酒ペアリングディナー(予約制)',
startDate: '2026-03-06', startTime: '18:30',
endDate: '2026-03-06', endTime: '21:00',
location: '○○レストラン プライベートルーム',
address: '○○市中央2-8',
description: '地酒専門店と料理人が組む全6品のコースディナー。季節の食材と日本酒のペアリングを楽しめます。定員10名。要予約・8,000円/人。',
category: '日本酒',
url: 'https://example.com/sake-dinner',
source: '地酒専門店ニュースレター',
interestTags: ['sake', 'food', 'learn'],
audienceTags: ['couple', 'solo'],
isFree: false, noRsvp: false, isOutdoor: false
},
{
id: '5',
title: '春のクラフトフェア 2026',
startDate: '2026-03-07', startTime: '10:00',
endDate: '2026-03-08', endTime: '17:00',
location: '○○公園 野外広場',
address: '○○市北公園',
description: '全国から集まるクラフト作家80組が出展。アクセサリー、革工芸、テキスタイル、木工など多彩なジャンル。入場無料。',
category: 'クラフト',
url: 'https://example.com/craft',
source: '実行委員会HP',
interestTags: ['craft', 'art', 'market'],
audienceTags: ['couple', 'family', 'solo'],
isFree: true, noRsvp: true, isOutdoor: true
},
{
id: '6',
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: '町内会回覧板',
interestTags: ['learn', 'volunteer'],
audienceTags: ['family', 'senior', 'solo'],
isFree: true, noRsvp: true, isOutdoor: false
},
{
id: '7',
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',
interestTags: ['craft', 'culture', 'learn'],
audienceTags: ['couple', 'family', 'solo', 'senior'],
isFree: false, noRsvp: false, isOutdoor: false
},
{
id: '8',
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',
interestTags: ['music', 'art'],
audienceTags: ['couple', 'family', 'solo', 'senior'],
isFree: false, noRsvp: false, isOutdoor: false
},
{
id: '9',
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',
interestTags: ['volunteer', 'outdoor'],
audienceTags: ['family', 'solo', 'senior'],
isFree: true, noRsvp: true, isOutdoor: true
},
{
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',
interestTags: ['outdoor', 'learn'],
audienceTags: ['family', 'solo', 'senior'],
isFree: true, noRsvp: true, isOutdoor: true
},
{
id: '11',
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',
interestTags: ['film', 'kids'],
audienceTags: ['family'],
isFree: true, noRsvp: true, isOutdoor: false
},
{
id: '12',
title: 'まちなかマルシェ4月',
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',
interestTags: ['market', 'food'],
audienceTags: ['family', 'couple', 'solo'],
isFree: true, noRsvp: true, isOutdoor: false
}
];
// ---- ユーティリティ ----
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);
if (ev.startDate <= next7.toISOString().slice(0,10)) 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) {
return ev.startDate === ev.endDate
? `${ev.startDate.replace(/-/g,'/')} ${ev.startTime} - ${ev.endTime}`
: `${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 prefs = JSON.parse(localStorage.getItem('events-prefs') || '{"interests":[],"audience":[],"options":[]}');
let pendingPrefs = null;
function matchesPrefs(ev, p) {
if (!p) return true;
const hasInterest = p.interests.length === 0 || p.interests.some(id => (ev.interestTags||[]).includes(id));
const hasAudience = p.audience.length === 0 || p.audience.some(id => (ev.audienceTags||[]).includes(id));
let hasOption = true;
if (p.options.includes('free') && !ev.isFree) hasOption = false;
if (p.options.includes('no-rsvp') && !ev.noRsvp) hasOption = false;
if (p.options.includes('outdoor') && !ev.isOutdoor) hasOption = false;
if (p.options.includes('indoor') && ev.isOutdoor) hasOption = false;
return hasInterest && hasAudience && hasOption;
}
function hasActiveFilters(p) {
return p.interests.length > 0 || p.audience.length > 0 || p.options.length > 0;
}
// ---- State ----
let currentFilter = 'today';
let allEvents = [];
let savedIds = new Set(JSON.parse(localStorage.getItem('events-saved') || '[]'));
// ---- Filter / Group ----
function filterByDate(events, filter) {
const t = today();
const d = new Date(); d.setDate(d.getDate() - d.getDay()); // 週日曜
const e = new Date(d); e.setDate(d.getDate() + 6); // 週土曜
const ws = d.toISOString().slice(0,10);
const we = e.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;
}
function groupEvents(events) {
const g = { active:{label:'開催中',events:[]}, upcoming:{label:'今週・近日',events:[]},
future:{label:'来月以降',events:[]}, ended:{label:'終了',events:[]} };
for (const ev of events) g[getStatus(ev)].events.push(ev);
for (const s of Object.values(g)) s.events.sort((a,b)=>(a.startDate+a.startTime).localeCompare(b.startDate+b.startTime));
return g;
}
// ---- Render ----
function renderInterestChips(ev) {
const tags = [...(ev.interestTags||[]), ...(ev.audienceTags||[])].slice(0,2);
if (!tags.length) return '';
const iDef = INTEREST_TAGS.concat(AUDIENCE_TAGS);
return tags.map(id => {
const def = iDef.find(x=>x.id===id);
return def ? `<span class="tag tag-interest">${escapeHTML(def.label)}</span>` : '';
}).join('');
}
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>
${renderInterestChips(ev)}
</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 dateFiltered = filterByDate(allEvents, currentFilter);
const filtered = dateFiltered.filter(ev => matchesPrefs(ev, prefs));
const list = document.getElementById('eventList');
// カウント (プレフィルタ前の数)
document.getElementById('count-today').textContent = filterByDate(allEvents,'today').filter(ev=>matchesPrefs(ev,prefs)).length;
document.getElementById('count-week').textContent = filterByDate(allEvents,'week').filter(ev=>matchesPrefs(ev,prefs)).length;
document.getElementById('count-all').textContent = allEvents.filter(ev=>matchesPrefs(ev,prefs)).length;
// アクティブフィルターチップ
renderActiveChips();
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 = '';
for (const key of ['active','upcoming','future','ended']) {
const g = groups[key];
if (!g.events.length) continue;
html += `<div class="section-label">${g.label}</div>`;
html += g.events.map(renderCard).join('');
}
list.innerHTML = html;
} else {
list.innerHTML = [...filtered]
.sort((a,b)=>(a.startDate+a.startTime).localeCompare(b.startDate+b.startTime))
.map(renderCard).join('');
}
lucide.createIcons();
}
function renderActiveChips() {
const container = document.getElementById('activeFilters');
const btn = document.getElementById('filterBtn');
if (!hasActiveFilters(prefs)) {
container.classList.add('hidden');
btn.classList.remove('has-filter');
return;
}
container.classList.remove('hidden');
btn.classList.add('has-filter');
const allDefs = INTEREST_TAGS.concat(AUDIENCE_TAGS).concat(OPTION_TAGS);
const allSelected = [...prefs.interests, ...prefs.audience, ...prefs.options];
container.innerHTML = allSelected.map(id => {
const def = allDefs.find(x=>x.id===id);
return def ? `<span class="chip" onclick="removeFilter('${id}')">
${escapeHTML(def.label)}<i data-lucide="x"></i></span>` : '';
}).join('');
lucide.createIcons();
}
window.removeFilter = function(id) {
prefs.interests = prefs.interests.filter(x=>x!==id);
prefs.audience = prefs.audience.filter(x=>x!==id);
prefs.options = prefs.options.filter(x=>x!==id);
localStorage.setItem('events-prefs', JSON.stringify(prefs));
renderTimeline();
};
// ---- データ読み込み ----
async function loadEvents() {
document.getElementById('eventList').innerHTML =
'<div class="loading-state"><div class="spinner"></div></div>';
try {
// Synology API が設定されていればそちらを優先、なければ Vercel serverless を使用
const API_URL = localStorage.getItem('events-api-url')
|| 'https://posimai-lab.tail72e846.ts.net/events/api/events';
const res = await fetch(API_URL);
if (!res.ok) throw new Error('API error');
const data = await res.json();
allEvents = data.events || [];
localStorage.setItem('events-cache', JSON.stringify(allEvents));
} catch {
const cached = localStorage.getItem('events-cache');
allEvents = cached ? JSON.parse(cached) : 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]));
window.open(`https://posimai-brain.vercel.app/?url=${encodeURIComponent(ev.url)}&title=${encodeURIComponent(ev.title)}`, '_blank');
showToast('Brainに保存しました');
renderTimeline();
}
// ---- Detail Sheet ----
window.openDetail = function(id) {
const ev = allEvents.find(x=>x.id===id);
if (!ev) return;
const status = getStatus(ev);
const saved = savedIds.has(id);
const allDefs = INTEREST_TAGS.concat(AUDIENCE_TAGS);
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>`;
const tags = [...(ev.interestTags||[]), ...(ev.audienceTags||[])];
document.getElementById('sheetTags').innerHTML = tags.map(tid => {
const def = allDefs.find(x=>x.id===tid);
const isAudience = AUDIENCE_TAGS.some(x=>x.id===tid);
return def ? `<span class="tag ${isAudience?'tag-audience':'tag-interest'}">${escapeHTML(def.label)}</span>` : '';
}).join('');
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]));
window.open(`https://posimai-brain.vercel.app/?url=${encodeURIComponent(ev.url)}&title=${encodeURIComponent(ev.title)}`, '_blank');
showToast('Brainに保存しました');
brainBtn.innerHTML = '<i data-lucide="bookmark-check"></i> 保存済み';
brainBtn.style.color = 'var(--accent)';
lucide.createIcons(); renderTimeline();
};
openSheet('detailSheet');
lucide.createIcons();
};
// ---- Preference Sheet ----
function buildPrefTags(container, defs, selectedArr, colorClass) {
container.innerHTML = defs.map(def => `
<span class="pref-tag ${selectedArr.includes(def.id)?'selected':''}" data-id="${def.id}">
<i data-lucide="${def.icon}"></i>${escapeHTML(def.label)}
</span>`).join('');
container.querySelectorAll('.pref-tag').forEach(el => {
el.addEventListener('click', () => el.classList.toggle('selected'));
});
}
function openPrefSheet() {
pendingPrefs = JSON.parse(JSON.stringify(prefs));
buildPrefTags(document.getElementById('interestTagsList'), INTEREST_TAGS, prefs.interests, 'interest');
buildPrefTags(document.getElementById('audienceTagsList'), AUDIENCE_TAGS, prefs.audience, 'audience');
buildPrefTags(document.getElementById('optionTagsList'), OPTION_TAGS, prefs.options, 'option');
openSheet('prefSheet');
lucide.createIcons();
}
document.getElementById('applyPref').addEventListener('click', () => {
prefs.interests = [...document.querySelectorAll('#interestTagsList .pref-tag.selected')].map(el=>el.dataset.id);
prefs.audience = [...document.querySelectorAll('#audienceTagsList .pref-tag.selected')].map(el=>el.dataset.id);
prefs.options = [...document.querySelectorAll('#optionTagsList .pref-tag.selected')].map(el=>el.dataset.id);
localStorage.setItem('events-prefs', JSON.stringify(prefs));
closeSheet('prefSheet');
renderTimeline();
if (hasActiveFilters(prefs)) showToast('絞り込みを適用しました');
});
document.getElementById('clearPref').addEventListener('click', () => {
document.querySelectorAll('#prefSheet .pref-tag.selected').forEach(el=>el.classList.remove('selected'));
});
// ---- Sheet Open/Close ----
function openSheet(id) {
document.getElementById(id).classList.add('open');
document.getElementById('backdrop').classList.add('open');
}
function closeSheet(id) {
document.getElementById(id).classList.remove('open');
if (!document.querySelector('.bottom-sheet.open')) {
document.getElementById('backdrop').classList.remove('open');
}
}
document.getElementById('closeDetail').addEventListener('click', () => closeSheet('detailSheet'));
document.getElementById('closePref').addEventListener('click', () => closeSheet('prefSheet'));
document.getElementById('backdrop').addEventListener('click', () => {
closeSheet('detailSheet'); closeSheet('prefSheet');
document.getElementById('backdrop').classList.remove('open');
});
document.getElementById('filterBtn').addEventListener('click', openPrefSheet);
// ---- 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();
});
});
// ---- 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);
}
// ---- テーマ ----
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 next = (document.documentElement.getAttribute('data-theme')||'dark') === 'dark' ? 'light' : 'dark';
localStorage.setItem('events-theme', next); applyTheme(next);
});
// ---- Refresh ----
document.getElementById('refreshBtn').addEventListener('click', () => {
const icon = document.querySelector('#refreshBtn i');
icon.style.transition = 'transform 0.5s'; icon.style.transform = 'rotate(360deg)';
setTimeout(()=>{ icon.style.transform=''; }, 500);
loadEvents().then(()=>showToast('最新情報に更新しました'));
});
// ---- Header Date ----
function updateHeaderDate() {
const d = new Date();
const days = ['日','月','火','水','木','金','土'];
document.getElementById('headerDate').textContent =
`${d.getMonth()+1}/${d.getDate()}(${days[d.getDay()]})`;
}
// ---- PWA Service Worker ----
if ('serviceWorker' in navigator) {
window.addEventListener('load', ()=>{ navigator.serviceWorker.register('/sw.js').catch(()=>{}); });
}
// ---- Init ----
applyTheme(localStorage.getItem('events-theme') || 'dark');
updateHeaderDate();
lucide.createIcons();
loadEvents();
</script>
</body>
</html>