1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
|
---
// 行事予定の一覧表。データは src/data/schedule/*.json から読む。
// セルの中身は HTML(リンクを含む)としてそのまま出力する。
import meetings from '@/data/schedule/meetings.json';
import events from '@/data/schedule/events.json';
import { entryDates, mainStartMonth } from '@/lib/schedule-dates';
type Row = { date: string; title: string; place: string; note: string; source: string };
type TableData = { columns: string[]; rows: Row[] };
const {
variant,
rows,
scope = 'all',
} = Astro.props as { variant: 'meeting' | 'event'; rows?: Row[]; scope?: 'all' | 'main' };
const base: TableData = (variant === 'meeting' ? meetings : events) as TableData;
// rows を渡された場合はその行だけを表示する(月別・年度別アーカイブ用)
let list = rows ?? base.rows;
// scope="main" のときは、前月以降に開催される予定だけを表示する。
// 前々月以前の終了分はアーカイブに移し、メインページが年々肥大化しないようにする。
if (scope === 'main') {
const start = `${mainStartMonth()}-01`;
list = list.filter((row) =>
entryDates(variant, row.date, row.place).some((date) => date >= start),
);
}
const data: TableData = { columns: base.columns, rows: list };
// 「令和9年1月28日(木)」のような長い日付で列が広がるため、「令和9年」の直後で改行する。
const dateHtml = (html: string) =>
html.replace(/(令和[0-90-9]+年)(?!\s*<br)/, '$1<br />');
---
<table>
<thead>
<tr>{data.columns.map((column) => <th>{column}</th>)}</tr>
</thead>
<tbody>
{
data.rows.map((row) => (
<tr>
<td set:html={dateHtml(row.date)} />
<td set:html={row.title} />
<td set:html={row.place} />
<td set:html={row.note} />
<td set:html={row.source} />
</tr>
))
}
</tbody>
</table>
<script>
// 行ごとの共有ボタン(X・Instagram・Facebook)。
// 行数が多いため、HTMLには書かず、このスクリプトで後から差し込む。
// アイコンは CSS(mask-image)で描くので、ボタンには文字もSVGも入れない。
if (!document.documentElement.dataset.scheduleShareInit) {
document.documentElement.dataset.scheduleShareInit = '1';
const KINDS: { kind: string; label: string }[] = [
{ kind: 'x', label: 'Xでシェア' },
{ kind: 'instagram', label: 'Instagramでシェア' },
{ kind: 'facebook', label: 'Facebookでシェア' },
];
function attachButtons() {
for (const row of document.querySelectorAll<HTMLTableRowElement>(
'.schedule-table tbody tr',
)) {
const titleCell = row.children[1];
if (!titleCell || titleCell.querySelector('.schedule-share')) continue;
const wrap = document.createElement('span');
wrap.className = 'schedule-share';
for (const { kind, label } of KINDS) {
const button = document.createElement('button');
button.type = 'button';
button.className = `schedule-share-btn is-${kind}`;
button.dataset.share = kind;
button.setAttribute('aria-label', label);
button.title = label;
wrap.appendChild(button);
}
titleCell.appendChild(wrap);
}
}
const strip = (el: Element | null | undefined) =>
(el?.textContent ?? '').replace(/\s+/g, ' ').trim();
// 共有する文面(日時+タイトル+時間と場所+申込み等)
function shareText(row: Element): string {
const cells = row.children;
const lines = [`${strip(cells[0])} ${strip(cells[1])}`];
const place = strip(cells[2]);
const note = strip(cells[3]);
if (place) lines.push(place);
if (note) lines.push(note.length > 120 ? `${note.slice(0, 120)}…` : note);
return lines.join('\n');
}
function rowUrl(row: Element): string {
const base = location.origin + location.pathname;
return row.id ? `${base}#${row.id}` : base;
}
document.addEventListener('click', async (event) => {
const target = event.target as HTMLElement | null;
const button = target?.closest<HTMLElement>('.schedule-share-btn');
if (!button) return;
const row = button.closest('tr');
if (!row) return;
const text = shareText(row);
const url = rowUrl(row);
const kind = button.dataset.share;
if (kind === 'x') {
window.open(
`https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`,
'_blank',
'noopener',
);
return;
}
if (kind === 'facebook') {
// Facebookのsharerは文面を指定できない(ページのOGPが使われる)
window.open(
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
'_blank',
'noopener',
);
return;
}
// Instagramはテキストを渡せる共有URLがないため、端末の共有メニューを使う
const share = (navigator as Navigator & { share?: (data: ShareData) => Promise<void> })
.share;
if (share) {
try {
await share.call(navigator, { text, url });
} catch {
// キャンセルされた場合は何もしない
}
return;
}
try {
await navigator.clipboard.writeText(`${text}\n${url}`);
window.alert(
'Instagram はスマートフォンの共有メニューから投稿できます(このブラウザは共有メニューに対応していません)。共有する文面をコピーしました。',
);
} catch {
window.alert('Instagram はスマートフォンの共有メニューから投稿できます。');
}
});
attachButtons();
// アーカイブの絞り込みなどで行が増えた場合にも備える
document.addEventListener('click', () => setTimeout(attachButtons, 0));
}
</script>
|