posimai-tech-events/index.html

1387 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="robots" content="noindex, nofollow">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="ITエンジニア・デザイナーの学習を加速するイベント情報">
<title>Posimai Tech 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="#0D0D0D">
<meta name="mobile-web-app-capable" content="yes">
<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="Posimai Tech">
<script src="https://unpkg.com/lucide@0.344.0/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: #0D0D0D;
--surface: #1A1A1A;
--surface2: #252525;
--border: #2D2D2D;
--text: #F3F4F6;
--text2: #9CA3AF;
--text3: #6B7280;
--accent: #6EE7B7;
--accent-dim: rgba(110, 231, 183, 0.12);
--accent-border: rgba(110, 231, 183, 0.25);
--status-active: #6EE7B7;
--status-upcoming: #6EE7B7;
--status-ended: #4B5563;
--rose: #F87171;
--radius: 12px;
--header-h: 52px;
font-family: 'Inter', system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color-scheme: dark;
}
[data-theme="light"] {
--bg: #F5F5F5;
--surface: #FFFFFF;
--surface2: #EBEBEB;
--border: #E0E0E0;
--text: #111111;
--text2: #555555;
--text3: #888888;
--accent: #059669;
--accent-dim: rgba(5, 150, 105, 0.08);
--accent-border: rgba(5, 150, 105, 0.25);
--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: var(--header-h);
min-height: var(--header-h);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--border);
background: rgba(26, 26, 26, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 50;
}
[data-theme="light"] .header {
background: rgba(255, 255, 255, 0.92);
}
.header-logo {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.header-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
flex-shrink: 0;
}
.header-title {
font-size: 14px;
font-weight: 600;
letter-spacing: -0.3px;
white-space: nowrap;
}
.header-meta {
font-size: 12px;
color: var(--text3);
font-weight: 400;
margin-left: 2px;
}
.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-border);
color: var(--accent);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
}
.chip:hover {
background: var(--accent-dim);
opacity: 0.85;
}
.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-border);
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: #0D1210;
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="header-logo">
<div class="header-dot"></div>
<div>
<span class="header-title">Posimai Tech</span>
<span class="header-meta" id="headerDate"></span>
</div>
</div>
<div class="header-actions">
<button class="icon-btn" id="filterBtn" title="絞り込み">
<i data-lucide="sliders-horizontal" style="width:16px;height:16px;"></i>
</button>
<button class="icon-btn" id="refreshBtn" title="更新">
<i data-lucide="refresh-cw" style="width:16px;height:16px;"></i>
</button>
<button class="icon-btn" id="themeBtn" title="テーマ切替">
<i data-lucide="sun" style="width:16px;height:16px;"></i>
</button>
</div>
</header>
<!-- Filter Tabs -->
<nav class="filter-bar">
<button class="filter-tab active" data-filter="all">すべて</button>
<button class="filter-tab" data-filter="frontend">フロント</button>
<button class="filter-tab" data-filter="backend">バック</button>
<button class="filter-tab" data-filter="design">デザイン</button>
<button class="filter-tab" data-filter="ai">AI</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: 'frontend', label: 'フロントエンド', icon: 'layout' },
{ id: 'backend', label: 'バックエンド', icon: 'server' },
{ id: 'design', label: 'デザイン・UX', icon: 'pen-tool' },
{ id: 'ai', label: 'AI・機械学習', icon: 'bot' },
{ id: 'infra', label: 'インフラ・クラウド', icon: 'cloud' },
{ id: 'mobile', label: 'iOS/Android', icon: 'smartphone' },
{ id: 'data', label: 'データサイエンス', icon: 'database' },
{ id: 'pm', label: 'プロダクトマネジメント', icon: 'briefcase' },
{ id: 'beginner', label: '初心者歓迎', icon: 'smile' }
];
const AUDIENCE_TAGS = [
{ id: 'meetup', label: '交流会・ミートアップ', icon: 'users' },
{ id: 'mokumoku', label: 'もくもく会', icon: 'coffee' },
{ id: 'seminar', label: 'セミナー・勉強会', icon: 'book-open' },
{ id: 'handson', label: 'ハンズオン', icon: 'hammer' }
];
const OPTION_TAGS = [
{ id: 'free', label: '無料', icon: 'circle-dollar-sign' },
{ id: 'online', label: 'オンライン開催', icon: 'monitor-play' },
{ id: 'offline', label: 'オフライン(会場)開催', icon: 'building' }
];
// ---- モックイベントデータinterestTags / audienceTags 付き)----
// MOCK_EVENTS removed for Connpass API mapping
// ---- ユーティリティ ----
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 = 'all'; // all, frontend, backend, design, ai
let allEvents = [];
let savedIds = new Set(JSON.parse(localStorage.getItem('events-saved') || '[]'));
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() {
// 取得したイベントに対してクライアントサイドの設定Prefs絞り込みをかける
const filtered = allEvents.filter(ev => matchesPrefs(ev, prefs));
const list = document.getElementById('eventList');
// アクティブフィルターチップ
renderActiveChips();
if (filtered.length === 0) {
list.innerHTML = `<div class="empty-state">
<i data-lucide="calendar-x"></i>
<p>この条件のイベントは見つかりません。<br>絞り込みを変更してみてください。</p>
</div>`;
lucide.createIcons(); return;
}
// Roleベースタブではすべてを時系列で並べる、もしくは月ごとにグループ化する
// 今回はシンプルに日付順リスト表示
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();
};
// ---- タブフィルター キーワード ----
const TAB_KEYWORDS = {
frontend: ['フロントエンド', 'React', 'Vue', 'TypeScript', 'Next.js', 'Svelte'],
backend: ['バックエンド', 'Go', 'Rust', 'Ruby', 'Python', 'PHP', 'API'],
design: ['デザイン', 'UX', 'UI', 'Figma'],
ai: ['AI', '機械学習', 'LLM', 'GPT', 'Claude', 'Gemini'],
};
function matchesTab(ev, tab) {
if (tab === 'all') return true;
const kws = TAB_KEYWORDS[tab] || [];
const haystack = (ev.title + ' ' + ev.description + ' ' + (ev.interestTags || []).join(' ')).toLowerCase();
return kws.some(kw => haystack.includes(kw.toLowerCase()));
}
// ---- データ読み込み (RSS経由) ----
const API_BASE = 'https://posimai-lab.tail72e846.ts.net/brain/api';
async function loadEvents(tabFilter = 'all') {
currentFilter = tabFilter;
document.getElementById('eventList').innerHTML =
'<div class="loading-state"><div class="spinner"></div></div>';
try {
// RSSプロキシ経由Doorkeeper + connpass
const response = await fetch(API_BASE + '/events/rss', {
signal: AbortSignal.timeout(10000),
});
if (!response.ok) throw new Error('RSS fetch failed: ' + response.status);
const data = await response.json();
allEvents = (data.events || []).filter(ev => matchesTab(ev, tabFilter));
localStorage.setItem('events-cache', JSON.stringify(data.events || []));
renderTimeline();
} catch (e) {
console.error('[Events] Load error:', e);
// フォールバック: キャッシュから全件取得してクライアントフィルタ
const cached = localStorage.getItem('events-cache');
const all = cached ? JSON.parse(cached) : [];
allEvents = all.filter(ev => matchesTab(ev, tabFilter));
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', () => {
if (currentFilter === btn.dataset.filter) return;
document.querySelectorAll('.filter-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.dataset.filter;
loadEvents(currentFilter); // タブ切り替え時にAPIを再コールする
});
});
// ---- 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(currentFilter).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) {
// Unregister old SWs to ensure fresh code
navigator.serviceWorker.getRegistrations().then(registrations => {
const unregisterPromises = registrations.map(reg => reg.unregister());
return Promise.all(unregisterPromises);
}).then(() => {
// Register fresh SW with cachebuster
return navigator.serviceWorker.register('/sw.js?v=3');
}).then(reg => {
if (reg.waiting) {
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}).catch(() => { });
}
// ---- Init ----
applyTheme(localStorage.getItem('events-theme') || 'dark');
updateHeaderDate();
lucide.createIcons();
loadEvents(currentFilter);
</script>
</body>
</html>