課程的檔案系統
實例 19 / 程式碼

data.js|白板資料層完整程式

本機示範模式與 Supabase 存取、公告清單與全文分開、附件與訂閱等工作集中在資料層。

完整內容・去識別版程式碼577 行 · 全文
來源位置(邏輯別名)Google Drive / CourseWorkspace / live-board-mvp / src / data.js
來源版本/日期2026-09-11 來源副本
資料取得方式Google Drive 實際檔案/文件匯出
使用與備份界線需要原專案模組與環境設定;本頁只顯示程式,不載入它、不連接 Supabase,也不讀寫公告。

以下說明為本次整理;正文區保留來源的內容與歷史語氣。

閱讀實際內容 ↓下載 .js回到地圖位置
網址與識別碼處理本檔未偵測到需要替換的私人網址或識別碼。假網址一律使用 example.invalid,不能用來開啟原資料;請換成自己有權使用的來源。查看處理規則。
1import { BOARD_SLUG, isDemoMode } from './config.js';
2import { sortPosts } from './sorting.js';
3import { supabase } from './supabase.js';
4import { nowIso } from './utils.js';
5
6const DEMO_STORAGE_KEY = 'live-board-mvp-demo-state-v2';
7const DEMO_CHANNEL_NAME = 'live-board-mvp-demo-sync';
8
9const ATTACHMENT_BUCKET = 'live-board-attachments';
10export const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
11export const ALLOWED_ATTACHMENT_TYPES = new Set([
12 'application/pdf',
13 'application/msword',
14 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
15 'application/vnd.ms-powerpoint',
16 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
17 'application/vnd.ms-excel',
18 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
19 'image/png',
20 'image/jpeg',
21 'image/gif',
22 'image/webp',
23 'application/zip',
24 'text/plain',
25 'text/csv',
26]);
28const boardFields = 'id, slug, title, is_active, is_archived, archived_at, archived_by, purge_after, sort_mode, created_by, created_at, updated_at';
29const publicBoardFields = 'id, slug, title, is_active, is_archived, sort_mode, created_at, updated_at';
30const postFields = 'id, board_id, title, body, sort_order, published, published_at, publish_at, unpublish_at, pinned, locked, locked_at, locked_by, default_collapsed, created_at, updated_at';
31const publicPostFields = 'id, board_id, title, body, sort_order, published, published_at, publish_at, unpublish_at, pinned, locked, default_collapsed, created_at, updated_at';
32const publicPostSummaryFields = 'id, board_id, title, sort_order, published, published_at, publish_at, unpublish_at, pinned, locked, default_collapsed, created_at, updated_at';
33const attachmentFields = 'id, board_id, post_id, storage_path, file_name, mime_type, file_size, created_by, created_at';
35const seedDemoState = {
36 boards: [
37 {
38 id: 'demo-board',
39 slug: BOARD_SLUG,
40 title: '課堂即時公佈欄',
41 is_active: true,
42 is_archived: false,
43 purge_after: null,
44 sort_mode: 'newest',
45 created_by: 'demo-user',
46 created_at: '2026-08-20T00:00:00.000Z',
47 updated_at: '2026-08-20T00:00:00.000Z',
48 },
49 ],
50 posts: [
51 {
52 id: 'demo-post-1',
53 board_id: 'demo-board',
54 title: '上課指令範例',
55 body: '請先開啟你慣用的 AI 工具,準備好之後回到這個白板。\n\n接著複製下一則公告中的指令。',
56 sort_order: 10,
57 published: true,
58 published_at: '2026-08-20T00:00:00.000Z',
59 publish_at: '2026-08-20T00:00:00.000Z',
60 unpublish_at: null,
61 pinned: false,
62 locked: false,
63 default_collapsed: true,
64 created_at: '2026-08-20T00:00:00.000Z',
65 updated_at: '2026-08-20T00:00:00.000Z',
66 },
67 {
68 id: 'demo-post-2',
69 board_id: 'demo-board',
70 title: '長文字也可以保留換行',
71 body: '這裡是示範內容。教師可以貼上多行指令、網址與課程提醒,學員端可以收摺,也可以直接複製完整內容。',
72 sort_order: 20,
73 published: true,
74 published_at: '2026-08-20T00:01:00.000Z',
75 publish_at: '2026-08-20T00:01:00.000Z',
76 unpublish_at: null,
77 pinned: true,
78 locked: false,
79 default_collapsed: true,
80 created_at: '2026-08-20T00:01:00.000Z',
81 updated_at: '2026-08-20T00:01:00.000Z',
82 },
83 ],
84};
86let demoBroadcastChannel;
88function clone(value) {
89 return JSON.parse(JSON.stringify(value));
92function normalizeDemoState(saved) {
93 if (saved?.boards && Array.isArray(saved.posts)) {
94 return {
95 boards: saved.boards.map((board) => ({ sort_mode: 'newest', is_archived: false, purge_after: null, ...board })),
96 posts: saved.posts.map((post) => ({
97 published_at: post.published ? post.updated_at || post.created_at : null,
98 publish_at: post.published ? post.updated_at || post.created_at : null,
99 unpublish_at: null,
100 attachments: [],
101 pinned: false,
102 locked: false,
103 ...post,
104 })),
105 };
106 }
108 if (saved?.board && Array.isArray(saved.posts)) {
109 return {
110 boards: [{ sort_mode: 'newest', is_archived: false, purge_after: null, ...saved.board }],
111 posts: saved.posts.map((post) => ({
112 published_at: post.published ? post.updated_at || post.created_at : null,
113 publish_at: post.published ? post.updated_at || post.created_at : null,
114 unpublish_at: null,
115 attachments: [],
116 pinned: false,
117 locked: false,
118 ...post,
119 })),
120 };
121 }
123 return clone(seedDemoState);
126function readDemoState() {
127 try {
128 const saved = localStorage.getItem(DEMO_STORAGE_KEY);
129 if (saved) return normalizeDemoState(JSON.parse(saved));
130 } catch {
131 // Continue with the seed data if local storage is unavailable.
132 }
133 return clone(seedDemoState);
136function writeDemoState(state, boardId) {
137 try {
138 localStorage.setItem(DEMO_STORAGE_KEY, JSON.stringify(state));
139 } catch {
140 // The current tab can still use the in-memory state.
141 }
142 if (typeof BroadcastChannel !== 'undefined') {
143 demoBroadcastChannel ||= new BroadcastChannel(DEMO_CHANNEL_NAME);
144 demoBroadcastChannel.postMessage({ boardId, at: nowIso() });
145 }
146 window.dispatchEvent(new CustomEvent('live-board-demo-change', { detail: boardId }));
149function getDemoBoard(state, slug, includeInactive = false) {
150 return state.boards.find((board) => board.slug === slug && (includeInactive || board.is_active));
153function toIsoOrNull(value) {
154 if (!value) return null;
155 const date = new Date(value);
156 if (Number.isNaN(date.getTime())) throw new Error('排程時間格式不正確。');
157 return date.toISOString();
160function isPostVisible(post, now = Date.now()) {
161 if (!post.published) return false;
162 const publishAt = post.publish_at || post.published_at;
163 const unpublishAt = post.unpublish_at ? new Date(post.unpublish_at).getTime() : null;
164 const publishTime = publishAt ? new Date(publishAt).getTime() : 0;
165 return (!publishAt || (!Number.isNaN(publishTime) && publishTime <= now))
166 && (unpublishAt == null || (!Number.isNaN(unpublishAt) && unpublishAt > now));
169function getNextScheduleAt(posts, now = Date.now()) {
170 const times = posts
171 .flatMap((post) => [post.publish_at, post.unpublish_at])
172 .filter(Boolean)
173 .map((value) => new Date(value).getTime())
174 .filter((value) => !Number.isNaN(value) && value > now)
175 .sort((a, b) => a - b);
176 return times[0] || null;
179function getPostPayload(values) {
180 const published = values.published !== false;
181 const publishAt = published ? (toIsoOrNull(values.publish_at) || nowIso()) : null;
182 const unpublishAt = published ? toIsoOrNull(values.unpublish_at) : null;
183 if (publishAt && unpublishAt && new Date(unpublishAt) <= new Date(publishAt)) {
184 throw new Error('自動下架時間必須晚於發布時間。');
185 }
187 return {
188 board_id: values.board_id,
189 title: String(values.title || '').trim(),
190 body: String(values.body || ''),
191 sort_order: Number(values.sort_order) || 10,
192 published,
193 publish_at: publishAt,
194 unpublish_at: unpublishAt,
195 default_collapsed: Boolean(values.default_collapsed),
196 pinned: Boolean(values.pinned),
197 locked: Boolean(values.locked),
198 };
201async function loadDemoBoard(slug = BOARD_SLUG, includeInactive = false) {
202 const state = readDemoState();
203 const board = getDemoBoard(state, slug, includeInactive);
204 if (!board) return { board: null, posts: [], nextRefreshAt: null };
205 const boardPosts = state.posts.filter((post) => post.board_id === board.id);
206 const posts = boardPosts.filter((post) => includeInactive || isPostVisible(post));
207 return {
208 board: clone(board),
209 posts: sortPosts(clone(posts), board.sort_mode),
210 nextRefreshAt: includeInactive ? null : getNextScheduleAt(boardPosts),
211 };
214export async function listBoards({ includeInactive = true } = {}) {
215 if (isDemoMode) {
216 const state = readDemoState();
217 return state.boards
218 .filter((board) => includeInactive || board.is_active)
219 .sort((a, b) => a.title.localeCompare(b.title, 'zh-Hant'))
220 .map(clone);
221 }
223 let query = supabase.from('boards').select(boardFields).order('title', { ascending: true });
224 if (!includeInactive) query = query.eq('is_active', true);
225 const { data, error } = await query;
226 if (error) throw error;
227 return data || [];
230export async function loadBoard(slug = BOARD_SLUG, { includeInactive = false } = {}) {
231 if (isDemoMode) return loadDemoBoard(slug, includeInactive);
233 let boardQuery = supabase
234 .from('boards')
235 .select(includeInactive ? boardFields : publicBoardFields)
236 .eq('slug', slug);
237 if (!includeInactive) boardQuery = boardQuery.eq('is_active', true);
238 const { data: board, error: boardError } = await boardQuery.maybeSingle();
239 if (boardError) throw boardError;
240 if (!board) return { board: null, posts: [] };
242 let postsQuery = supabase
243 .from('board_posts')
244 .select(includeInactive ? postFields : publicPostSummaryFields)
245 .eq('board_id', board.id);
246 if (!includeInactive) postsQuery = postsQuery.eq('published', true);
247 const { data: posts, error: postsError } = await postsQuery;
248 if (postsError) throw postsError;
249 const attachmentMap = new Map();
250 if (includeInactive) {
251 const { data: attachments, error: attachmentError } = await supabase
252 .from('board_post_attachments')
253 .select(attachmentFields)
254 .eq('board_id', board.id)
255 .order('created_at', { ascending: true });
256 if (attachmentError) throw attachmentError;
257 (attachments || []).forEach((attachment) => {
258 const { data: publicUrlData } = supabase.storage.from(ATTACHMENT_BUCKET).getPublicUrl(attachment.storage_path);
259 const enriched = { ...attachment, public_url: publicUrlData.publicUrl };
260 const list = attachmentMap.get(attachment.post_id) || [];
261 list.push(enriched);
262 attachmentMap.set(attachment.post_id, list);
263 });
264 }
265 const enrichedPosts = includeInactive
266 ? (posts || []).map((post) => ({ ...post, attachments: attachmentMap.get(post.id) || [] }))
267 : (posts || []).filter((post) => isPostVisible(post)).map((post) => ({ ...post }));
268 return {
269 board,
270 posts: sortPosts(enrichedPosts, board.sort_mode),
271 nextRefreshAt: includeInactive ? null : getNextScheduleAt(posts || []),
272 };
275export async function loadPostAttachments(postId) {
276 if (isDemoMode) {
277 const state = readDemoState();
278 const post = state.posts.find((item) => item.id === postId);
279 return clone(post?.attachments || []);
280 }
282 const { data, error } = await supabase
283 .from('board_post_attachments')
284 .select(attachmentFields)
285 .eq('post_id', postId)
286 .order('created_at', { ascending: true });
287 if (error) throw error;
288 return (data || []).map((attachment) => {
289 const { data: publicUrlData } = supabase.storage.from(ATTACHMENT_BUCKET).getPublicUrl(attachment.storage_path);
290 return { ...attachment, public_url: publicUrlData.publicUrl };
291 });
294export async function loadPostContent(postId) {
295 if (isDemoMode) {
296 const state = readDemoState();
297 const post = state.posts.find((item) => item.id === postId);
298 return post && isPostVisible(post) ? clone(post) : null;
299 }
301 const { data, error } = await supabase
302 .from('board_posts')
303 .select(publicPostFields)
304 .eq('id', postId)
305 .eq('published', true)
306 .maybeSingle();
307 if (error) throw error;
308 return data && isPostVisible(data) ? data : null;
311export async function createBoard(values, userId) {
312 const payload = {
313 slug: String(values.slug || '').trim(),
314 title: String(values.title || '').trim() || '課堂即時公佈欄',
315 sort_mode: values.sort_mode || 'newest',
316 is_active: values.is_active !== false,
317 purge_after: toIsoOrNull(values.purge_after),
318 created_by: userId,
319 };
321 if (isDemoMode) {
322 const state = readDemoState();
323 if (state.boards.some((board) => board.slug === payload.slug)) throw new Error('白板代碼已存在,請換一個代碼。');
324 const timestamp = nowIso();
325 const board = { ...payload, is_archived: false, purge_after: null, id: `demo-board-${crypto.randomUUID()}`, created_at: timestamp, updated_at: timestamp };
326 state.boards.push(board);
327 writeDemoState(state, board.id);
328 return clone(board);
329 }
331 const { data, error } = await supabase.from('boards').insert(payload).select(boardFields).single();
332 if (error) throw error;
333 return data;
336export async function ensureBoard(slug, title, userId) {
337 const existing = await loadBoard(slug, { includeInactive: true });
338 if (existing.board) return existing.board;
339 return createBoard({ slug, title, sort_mode: 'newest', is_active: true }, userId);
342export async function updateBoard(boardId, values) {
343 if (isDemoMode) {
344 const state = readDemoState();
345 const index = state.boards.findIndex((item) => item.id === boardId);
346 if (index < 0) throw new Error('找不到要更新的白板。');
347 state.boards[index] = { ...state.boards[index], ...values, updated_at: nowIso() };
348 writeDemoState(state, boardId);
349 return clone(state.boards[index]);
350 }
352 const { data, error } = await supabase.from('boards').update(values).eq('id', boardId).select(boardFields).single();
353 if (error) throw error;
354 return data;
357export async function savePost(values) {
358 const payload = getPostPayload(values);
360 if (isDemoMode) {
361 const state = readDemoState();
362 const timestamp = nowIso();
363 if (values.id) {
364 const index = state.posts.findIndex((post) => post.id === values.id);
365 if (index < 0) throw new Error('找不到要更新的公告。');
366 if (state.posts[index].locked && payload.body !== state.posts[index].body) {
367 throw new Error('這則公告的本文已鎖定,請先解除鎖定後再修改。');
368 }
369 const previous = state.posts[index];
370 state.posts[index] = {
371 ...previous,
372 ...payload,
373 published_at: payload.published && payload.publish_at <= timestamp ? previous.published_at || payload.publish_at : null,
374 locked_at: payload.locked ? previous.locked_at || timestamp : null,
375 updated_at: timestamp,
376 };
377 writeDemoState(state, payload.board_id);
378 return clone(state.posts[index]);
379 }
381 const post = {
382 ...payload,
383 id: `demo-post-${crypto.randomUUID()}`,
384 published_at: payload.published && payload.publish_at <= timestamp ? payload.publish_at : null,
385 publish_at: payload.publish_at,
386 unpublish_at: payload.unpublish_at,
387 locked_at: payload.locked ? timestamp : null,
388 created_at: timestamp,
389 updated_at: timestamp,
390 };
391 state.posts.push(post);
392 writeDemoState(state, payload.board_id);
393 return clone(post);
394 }
396 const query = values.id
397 ? supabase.from('board_posts').update(payload).eq('id', values.id)
398 : supabase.from('board_posts').insert(payload);
399 const { data, error } = await query.select(postFields).single();
400 if (error) throw error;
401 return data;
404export async function deletePost(postId) {
405 if (isDemoMode) {
406 const state = readDemoState();
407 const post = state.posts.find((item) => item.id === postId);
408 state.posts = state.posts.filter((item) => item.id !== postId);
409 writeDemoState(state, post?.board_id);
410 return;
411 }
413 const { data: attachments, error: attachmentError } = await supabase
414 .from('board_post_attachments')
415 .select('id, storage_path')
416 .eq('post_id', postId);
417 if (attachmentError) throw attachmentError;
418 if (attachments?.length) {
419 const { error: storageError } = await supabase.storage
420 .from(ATTACHMENT_BUCKET)
421 .remove(attachments.map((item) => item.storage_path));
422 if (storageError) throw storageError;
423 }
425 const { error: metadataError } = await supabase.from('board_post_attachments').delete().eq('post_id', postId);
426 if (metadataError) throw metadataError;
427 const { error } = await supabase.from('board_posts').delete().eq('id', postId);
428 if (error) throw error;
431export async function uploadAttachment({ boardId, postId, file, userId }) {
432 if (isDemoMode) {
433 const attachment = {
434 id: `demo-attachment-${crypto.randomUUID()}`,
435 board_id: boardId,
436 post_id: postId,
437 file_name: file.name,
438 mime_type: file.type || 'application/octet-stream',
439 file_size: file.size,
440 storage_path: `${boardId}/${postId}/${file.name}`,
441 public_url: URL.createObjectURL(file),
442 created_at: nowIso(),
443 };
444 const state = readDemoState();
445 const post = state.posts.find((item) => item.id === postId);
446 if (post) {
447 post.attachments ||= [];
448 post.attachments.push(attachment);
449 writeDemoState(state, boardId);
450 }
451 return attachment;
452 }
454 if (file.size > MAX_ATTACHMENT_SIZE) throw new Error('單一附件不可超過 10 MB。');
455 if (!ALLOWED_ATTACHMENT_TYPES.has(file.type)) throw new Error(`不支援的附件格式:${file.type || '未知格式'}。`);
457 const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(-120) || 'attachment';
458 const storagePath = `${boardId}/${postId}/${crypto.randomUUID()}-${safeName}`;
459 const { error: uploadError } = await supabase.storage
460 .from(ATTACHMENT_BUCKET)
461 .upload(storagePath, file, { cacheControl: '3600', contentType: file.type, upsert: false });
462 if (uploadError) throw uploadError;
464 const { data: publicUrlData } = supabase.storage.from(ATTACHMENT_BUCKET).getPublicUrl(storagePath);
465 const { data, error } = await supabase
466 .from('board_post_attachments')
467 .insert({
468 board_id: boardId,
469 post_id: postId,
470 storage_path: storagePath,
471 file_name: file.name,
472 mime_type: file.type,
473 file_size: file.size,
474 created_by: userId,
475 })
476 .select(attachmentFields)
477 .single();
478 if (error) {
479 await supabase.storage.from(ATTACHMENT_BUCKET).remove([storagePath]);
480 throw error;
481 }
482 return { ...data, public_url: publicUrlData.publicUrl };
485export async function deleteAttachment(attachment) {
486 if (isDemoMode) {
487 const state = readDemoState();
488 const post = state.posts.find((item) => item.id === attachment.post_id);
489 if (post) post.attachments = (post.attachments || []).filter((item) => item.id !== attachment.id);
490 writeDemoState(state, attachment.board_id);
491 return;
492 }
493 const { error: storageError } = await supabase.storage.from(ATTACHMENT_BUCKET).remove([attachment.storage_path]);
494 if (storageError) throw storageError;
495 const { error } = await supabase.from('board_post_attachments').delete().eq('id', attachment.id);
496 if (error) throw error;
499export async function setBoardArchived(boardId, archived, { purgeAfter = null } = {}) {
500 const values = {
501 is_archived: Boolean(archived),
502 purge_after: archived ? toIsoOrNull(purgeAfter) : null,
503 };
504 return updateBoard(boardId, values);
507export async function reorderPosts(posts) {
508 if (isDemoMode) {
509 const state = readDemoState();
510 const order = new Map(posts.map((post, index) => [post.id, (index + 1) * 10]));
511 state.posts = state.posts.map((post) => ({
512 ...post,
513 sort_order: order.get(post.id) ?? post.sort_order,
514 updated_at: nowIso(),
515 }));
516 writeDemoState(state, posts[0]?.board_id);
517 return;
518 }
520 const updates = posts.map((post, index) =>
521 supabase.from('board_posts').update({ sort_order: (index + 1) * 10 }).eq('id', post.id),
522 );
523 const results = await Promise.all(updates);
524 const failed = results.find((result) => result.error);
525 if (failed?.error) throw failed.error;
528export async function loadAuditLogs(postId) {
529 if (isDemoMode) return [];
530 const { data, error } = await supabase
531 .from('board_post_audit_logs')
532 .select('id, action, actor_id, changed_fields, created_at')
533 .eq('post_id', postId)
534 .order('created_at', { ascending: false });
535 if (error) throw error;
536 return data || [];
539export function subscribeToBoard(boardId, onChange, onStatus) {
540 if (isDemoMode) {
541 const handleChange = (event) => {
542 if (event.detail === boardId) onChange({ board_id: boardId });
543 };
544 const handleStorage = (event) => {
545 if (event.key === DEMO_STORAGE_KEY) onChange({ board_id: boardId });
546 };
547 let channel;
548 if (typeof BroadcastChannel !== 'undefined') {
549 channel = new BroadcastChannel(DEMO_CHANNEL_NAME);
550 channel.onmessage = (event) => {
551 if (event.data?.boardId === boardId) onChange({ board_id: boardId });
552 };
553 }
554 window.addEventListener('live-board-demo-change', handleChange);
555 window.addEventListener('storage', handleStorage);
556 onStatus?.({ connected: true, label: '本機示範同步', kind: 'demo' });
557 return () => {
558 channel?.close();
559 window.removeEventListener('live-board-demo-change', handleChange);
560 window.removeEventListener('storage', handleStorage);
561 };
562 }
564 const channel = supabase
565 .channel(`board:${boardId}`, { config: { broadcast: { self: false } } })
566 .on('broadcast', { event: 'board_changed' }, (payload) => {
567 if (!payload.payload?.board_id || payload.payload.board_id === boardId) onChange(payload.payload || { board_id: boardId });
568 })
569 .subscribe((status) => {
570 if (status === 'SUBSCRIBED') onStatus?.({ connected: true, label: '即時同步中', kind: 'live' });
571 else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
572 onStatus?.({ connected: false, label: '即時連線中斷,使用 HTTPS 備援', kind: 'fallback' });
573 }
574 });
576 return () => supabase.removeChannel(channel);
資料依據:data.js,全文;來源版本 2026-09-11 來源副本;擷取/收錄 2026-09-13。
未公開原來源分享位置;此處的來源路徑是展示別名。
本收錄文字 SHA-256:546334abfcad072fb96c2ec522a5567a9616af6f44134830702244d361143355
完整收錄清單與版本資訊