import { BOARD_SLUG, isDemoMode } from './config.js';
import { sortPosts } from './sorting.js';
import { supabase } from './supabase.js';
import { nowIso } from './utils.js';
const DEMO_STORAGE_KEY = 'live-board-mvp-demo-state-v2';
const DEMO_CHANNEL_NAME = 'live-board-mvp-demo-sync';
const ATTACHMENT_BUCKET = 'live-board-attachments';
export const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
export const ALLOWED_ATTACHMENT_TYPES = new Set([
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/zip',
'text/plain',
'text/csv',
]);
const boardFields = 'id, slug, title, is_active, is_archived, archived_at, archived_by, purge_after, sort_mode, created_by, created_at, updated_at';
const publicBoardFields = 'id, slug, title, is_active, is_archived, sort_mode, created_at, updated_at';
const 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';
const publicPostFields = 'id, board_id, title, body, sort_order, published, published_at, publish_at, unpublish_at, pinned, locked, default_collapsed, created_at, updated_at';
const publicPostSummaryFields = 'id, board_id, title, sort_order, published, published_at, publish_at, unpublish_at, pinned, locked, default_collapsed, created_at, updated_at';
const attachmentFields = 'id, board_id, post_id, storage_path, file_name, mime_type, file_size, created_by, created_at';
const seedDemoState = {
boards: [
{
id: 'demo-board',
slug: BOARD_SLUG,
title: '課堂即時公佈欄',
is_active: true,
is_archived: false,
purge_after: null,
sort_mode: 'newest',
created_by: 'demo-user',
created_at: '2026-08-20T00:00:00.000Z',
updated_at: '2026-08-20T00:00:00.000Z',
},
],
posts: [
{
id: 'demo-post-1',
board_id: 'demo-board',
title: '上課指令範例',
body: '請先開啟你慣用的 AI 工具,準備好之後回到這個白板。\n\n接著複製下一則公告中的指令。',
sort_order: 10,
published: true,
published_at: '2026-08-20T00:00:00.000Z',
publish_at: '2026-08-20T00:00:00.000Z',
unpublish_at: null,
pinned: false,
locked: false,
default_collapsed: true,
created_at: '2026-08-20T00:00:00.000Z',
updated_at: '2026-08-20T00:00:00.000Z',
},
{
id: 'demo-post-2',
board_id: 'demo-board',
title: '長文字也可以保留換行',
body: '這裡是示範內容。教師可以貼上多行指令、網址與課程提醒,學員端可以收摺,也可以直接複製完整內容。',
sort_order: 20,
published: true,
published_at: '2026-08-20T00:01:00.000Z',
publish_at: '2026-08-20T00:01:00.000Z',
unpublish_at: null,
pinned: true,
locked: false,
default_collapsed: true,
created_at: '2026-08-20T00:01:00.000Z',
updated_at: '2026-08-20T00:01:00.000Z',
},
],
};
let demoBroadcastChannel;
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function normalizeDemoState(saved) {
if (saved?.boards && Array.isArray(saved.posts)) {
return {
boards: saved.boards.map((board) => ({ sort_mode: 'newest', is_archived: false, purge_after: null, ...board })),
posts: saved.posts.map((post) => ({
published_at: post.published ? post.updated_at || post.created_at : null,
publish_at: post.published ? post.updated_at || post.created_at : null,
unpublish_at: null,
attachments: [],
pinned: false,
locked: false,
...post,
})),
};
}
if (saved?.board && Array.isArray(saved.posts)) {
return {
boards: [{ sort_mode: 'newest', is_archived: false, purge_after: null, ...saved.board }],
posts: saved.posts.map((post) => ({
published_at: post.published ? post.updated_at || post.created_at : null,
publish_at: post.published ? post.updated_at || post.created_at : null,
unpublish_at: null,
attachments: [],
pinned: false,
locked: false,
...post,
})),
};
}
return clone(seedDemoState);
}
function readDemoState() {
try {
const saved = localStorage.getItem(DEMO_STORAGE_KEY);
if (saved) return normalizeDemoState(JSON.parse(saved));
} catch {
// Continue with the seed data if local storage is unavailable.
}
return clone(seedDemoState);
}
function writeDemoState(state, boardId) {
try {
localStorage.setItem(DEMO_STORAGE_KEY, JSON.stringify(state));
} catch {
// The current tab can still use the in-memory state.
}
if (typeof BroadcastChannel !== 'undefined') {
demoBroadcastChannel ||= new BroadcastChannel(DEMO_CHANNEL_NAME);
demoBroadcastChannel.postMessage({ boardId, at: nowIso() });
}
window.dispatchEvent(new CustomEvent('live-board-demo-change', { detail: boardId }));
}
function getDemoBoard(state, slug, includeInactive = false) {
return state.boards.find((board) => board.slug === slug && (includeInactive || board.is_active));
}
function toIsoOrNull(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) throw new Error('排程時間格式不正確。');
return date.toISOString();
}
function isPostVisible(post, now = Date.now()) {
if (!post.published) return false;
const publishAt = post.publish_at || post.published_at;
const unpublishAt = post.unpublish_at ? new Date(post.unpublish_at).getTime() : null;
const publishTime = publishAt ? new Date(publishAt).getTime() : 0;
return (!publishAt || (!Number.isNaN(publishTime) && publishTime <= now))
&& (unpublishAt == null || (!Number.isNaN(unpublishAt) && unpublishAt > now));
}
function getNextScheduleAt(posts, now = Date.now()) {
const times = posts
.flatMap((post) => [post.publish_at, post.unpublish_at])
.filter(Boolean)
.map((value) => new Date(value).getTime())
.filter((value) => !Number.isNaN(value) && value > now)
.sort((a, b) => a - b);
return times[0] || null;
}
function getPostPayload(values) {
const published = values.published !== false;
const publishAt = published ? (toIsoOrNull(values.publish_at) || nowIso()) : null;
const unpublishAt = published ? toIsoOrNull(values.unpublish_at) : null;
if (publishAt && unpublishAt && new Date(unpublishAt) <= new Date(publishAt)) {
throw new Error('自動下架時間必須晚於發布時間。');
}
return {
board_id: values.board_id,
title: String(values.title || '').trim(),
body: String(values.body || ''),
sort_order: Number(values.sort_order) || 10,
published,
publish_at: publishAt,
unpublish_at: unpublishAt,
default_collapsed: Boolean(values.default_collapsed),
pinned: Boolean(values.pinned),
locked: Boolean(values.locked),
};
}
async function loadDemoBoard(slug = BOARD_SLUG, includeInactive = false) {
const state = readDemoState();
const board = getDemoBoard(state, slug, includeInactive);
if (!board) return { board: null, posts: [], nextRefreshAt: null };
const boardPosts = state.posts.filter((post) => post.board_id === board.id);
const posts = boardPosts.filter((post) => includeInactive || isPostVisible(post));
return {
board: clone(board),
posts: sortPosts(clone(posts), board.sort_mode),
nextRefreshAt: includeInactive ? null : getNextScheduleAt(boardPosts),
};
}
export async function listBoards({ includeInactive = true } = {}) {
if (isDemoMode) {
const state = readDemoState();
return state.boards
.filter((board) => includeInactive || board.is_active)
.sort((a, b) => a.title.localeCompare(b.title, 'zh-Hant'))
.map(clone);
}
let query = supabase.from('boards').select(boardFields).order('title', { ascending: true });
if (!includeInactive) query = query.eq('is_active', true);
const { data, error } = await query;
if (error) throw error;
return data || [];
}
export async function loadBoard(slug = BOARD_SLUG, { includeInactive = false } = {}) {
if (isDemoMode) return loadDemoBoard(slug, includeInactive);
let boardQuery = supabase
.from('boards')
.select(includeInactive ? boardFields : publicBoardFields)
.eq('slug', slug);
if (!includeInactive) boardQuery = boardQuery.eq('is_active', true);
const { data: board, error: boardError } = await boardQuery.maybeSingle();
if (boardError) throw boardError;
if (!board) return { board: null, posts: [] };
let postsQuery = supabase
.from('board_posts')
.select(includeInactive ? postFields : publicPostSummaryFields)
.eq('board_id', board.id);
if (!includeInactive) postsQuery = postsQuery.eq('published', true);
const { data: posts, error: postsError } = await postsQuery;
if (postsError) throw postsError;
const attachmentMap = new Map();
if (includeInactive) {
const { data: attachments, error: attachmentError } = await supabase
.from('board_post_attachments')
.select(attachmentFields)
.eq('board_id', board.id)
.order('created_at', { ascending: true });
if (attachmentError) throw attachmentError;
(attachments || []).forEach((attachment) => {
const { data: publicUrlData } = supabase.storage.from(ATTACHMENT_BUCKET).getPublicUrl(attachment.storage_path);
const enriched = { ...attachment, public_url: publicUrlData.publicUrl };
const list = attachmentMap.get(attachment.post_id) || [];
list.push(enriched);
attachmentMap.set(attachment.post_id, list);
});
}
const enrichedPosts = includeInactive
? (posts || []).map((post) => ({ ...post, attachments: attachmentMap.get(post.id) || [] }))
: (posts || []).filter((post) => isPostVisible(post)).map((post) => ({ ...post }));
return {
board,
posts: sortPosts(enrichedPosts, board.sort_mode),
nextRefreshAt: includeInactive ? null : getNextScheduleAt(posts || []),
};
}
export async function loadPostAttachments(postId) {
if (isDemoMode) {
const state = readDemoState();
const post = state.posts.find((item) => item.id === postId);
return clone(post?.attachments || []);
}
const { data, error } = await supabase
.from('board_post_attachments')
.select(attachmentFields)
.eq('post_id', postId)
.order('created_at', { ascending: true });
if (error) throw error;
return (data || []).map((attachment) => {
const { data: publicUrlData } = supabase.storage.from(ATTACHMENT_BUCKET).getPublicUrl(attachment.storage_path);
return { ...attachment, public_url: publicUrlData.publicUrl };
});
}
export async function loadPostContent(postId) {
if (isDemoMode) {
const state = readDemoState();
const post = state.posts.find((item) => item.id === postId);
return post && isPostVisible(post) ? clone(post) : null;
}
const { data, error } = await supabase
.from('board_posts')
.select(publicPostFields)
.eq('id', postId)
.eq('published', true)
.maybeSingle();
if (error) throw error;
return data && isPostVisible(data) ? data : null;
}
export async function createBoard(values, userId) {
const payload = {
slug: String(values.slug || '').trim(),
title: String(values.title || '').trim() || '課堂即時公佈欄',
sort_mode: values.sort_mode || 'newest',
is_active: values.is_active !== false,
purge_after: toIsoOrNull(values.purge_after),
created_by: userId,
};
if (isDemoMode) {
const state = readDemoState();
if (state.boards.some((board) => board.slug === payload.slug)) throw new Error('白板代碼已存在,請換一個代碼。');
const timestamp = nowIso();
const board = { ...payload, is_archived: false, purge_after: null, id: `demo-board-${crypto.randomUUID()}`, created_at: timestamp, updated_at: timestamp };
state.boards.push(board);
writeDemoState(state, board.id);
return clone(board);
}
const { data, error } = await supabase.from('boards').insert(payload).select(boardFields).single();
if (error) throw error;
return data;
}
export async function ensureBoard(slug, title, userId) {
const existing = await loadBoard(slug, { includeInactive: true });
if (existing.board) return existing.board;
return createBoard({ slug, title, sort_mode: 'newest', is_active: true }, userId);
}
export async function updateBoard(boardId, values) {
if (isDemoMode) {
const state = readDemoState();
const index = state.boards.findIndex((item) => item.id === boardId);
if (index < 0) throw new Error('找不到要更新的白板。');
state.boards[index] = { ...state.boards[index], ...values, updated_at: nowIso() };
writeDemoState(state, boardId);
return clone(state.boards[index]);
}
const { data, error } = await supabase.from('boards').update(values).eq('id', boardId).select(boardFields).single();
if (error) throw error;
return data;
}
export async function savePost(values) {
const payload = getPostPayload(values);
if (isDemoMode) {
const state = readDemoState();
const timestamp = nowIso();
if (values.id) {
const index = state.posts.findIndex((post) => post.id === values.id);
if (index < 0) throw new Error('找不到要更新的公告。');
if (state.posts[index].locked && payload.body !== state.posts[index].body) {
throw new Error('這則公告的本文已鎖定,請先解除鎖定後再修改。');
}
const previous = state.posts[index];
state.posts[index] = {
...previous,
...payload,
published_at: payload.published && payload.publish_at <= timestamp ? previous.published_at || payload.publish_at : null,
locked_at: payload.locked ? previous.locked_at || timestamp : null,
updated_at: timestamp,
};
writeDemoState(state, payload.board_id);
return clone(state.posts[index]);
}
const post = {
...payload,
id: `demo-post-${crypto.randomUUID()}`,
published_at: payload.published && payload.publish_at <= timestamp ? payload.publish_at : null,
publish_at: payload.publish_at,
unpublish_at: payload.unpublish_at,
locked_at: payload.locked ? timestamp : null,
created_at: timestamp,
updated_at: timestamp,
};
state.posts.push(post);
writeDemoState(state, payload.board_id);
return clone(post);
}
const query = values.id
? supabase.from('board_posts').update(payload).eq('id', values.id)
: supabase.from('board_posts').insert(payload);
const { data, error } = await query.select(postFields).single();
if (error) throw error;
return data;
}
export async function deletePost(postId) {
if (isDemoMode) {
const state = readDemoState();
const post = state.posts.find((item) => item.id === postId);
state.posts = state.posts.filter((item) => item.id !== postId);
writeDemoState(state, post?.board_id);
return;
}
const { data: attachments, error: attachmentError } = await supabase
.from('board_post_attachments')
.select('id, storage_path')
.eq('post_id', postId);
if (attachmentError) throw attachmentError;
if (attachments?.length) {
const { error: storageError } = await supabase.storage
.from(ATTACHMENT_BUCKET)
.remove(attachments.map((item) => item.storage_path));
if (storageError) throw storageError;
}
const { error: metadataError } = await supabase.from('board_post_attachments').delete().eq('post_id', postId);
if (metadataError) throw metadataError;
const { error } = await supabase.from('board_posts').delete().eq('id', postId);
if (error) throw error;
}
export async function uploadAttachment({ boardId, postId, file, userId }) {
if (isDemoMode) {
const attachment = {
id: `demo-attachment-${crypto.randomUUID()}`,
board_id: boardId,
post_id: postId,
file_name: file.name,
mime_type: file.type || 'application/octet-stream',
file_size: file.size,
storage_path: `${boardId}/${postId}/${file.name}`,
public_url: URL.createObjectURL(file),
created_at: nowIso(),
};
const state = readDemoState();
const post = state.posts.find((item) => item.id === postId);
if (post) {
post.attachments ||= [];
post.attachments.push(attachment);
writeDemoState(state, boardId);
}
return attachment;
}
if (file.size > MAX_ATTACHMENT_SIZE) throw new Error('單一附件不可超過 10 MB。');
if (!ALLOWED_ATTACHMENT_TYPES.has(file.type)) throw new Error(`不支援的附件格式:${file.type || '未知格式'}。`);
const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(-120) || 'attachment';
const storagePath = `${boardId}/${postId}/${crypto.randomUUID()}-${safeName}`;
const { error: uploadError } = await supabase.storage
.from(ATTACHMENT_BUCKET)
.upload(storagePath, file, { cacheControl: '3600', contentType: file.type, upsert: false });
if (uploadError) throw uploadError;
const { data: publicUrlData } = supabase.storage.from(ATTACHMENT_BUCKET).getPublicUrl(storagePath);
const { data, error } = await supabase
.from('board_post_attachments')
.insert({
board_id: boardId,
post_id: postId,
storage_path: storagePath,
file_name: file.name,
mime_type: file.type,
file_size: file.size,
created_by: userId,
})
.select(attachmentFields)
.single();
if (error) {
await supabase.storage.from(ATTACHMENT_BUCKET).remove([storagePath]);
throw error;
}
return { ...data, public_url: publicUrlData.publicUrl };
}
export async function deleteAttachment(attachment) {
if (isDemoMode) {
const state = readDemoState();
const post = state.posts.find((item) => item.id === attachment.post_id);
if (post) post.attachments = (post.attachments || []).filter((item) => item.id !== attachment.id);
writeDemoState(state, attachment.board_id);
return;
}
const { error: storageError } = await supabase.storage.from(ATTACHMENT_BUCKET).remove([attachment.storage_path]);
if (storageError) throw storageError;
const { error } = await supabase.from('board_post_attachments').delete().eq('id', attachment.id);
if (error) throw error;
}
export async function setBoardArchived(boardId, archived, { purgeAfter = null } = {}) {
const values = {
is_archived: Boolean(archived),
purge_after: archived ? toIsoOrNull(purgeAfter) : null,
};
return updateBoard(boardId, values);
}
export async function reorderPosts(posts) {
if (isDemoMode) {
const state = readDemoState();
const order = new Map(posts.map((post, index) => [post.id, (index + 1) * 10]));
state.posts = state.posts.map((post) => ({
...post,
sort_order: order.get(post.id) ?? post.sort_order,
updated_at: nowIso(),
}));
writeDemoState(state, posts[0]?.board_id);
return;
}
const updates = posts.map((post, index) =>
supabase.from('board_posts').update({ sort_order: (index + 1) * 10 }).eq('id', post.id),
);
const results = await Promise.all(updates);
const failed = results.find((result) => result.error);
if (failed?.error) throw failed.error;
}
export async function loadAuditLogs(postId) {
if (isDemoMode) return [];
const { data, error } = await supabase
.from('board_post_audit_logs')
.select('id, action, actor_id, changed_fields, created_at')
.eq('post_id', postId)
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
}
export function subscribeToBoard(boardId, onChange, onStatus) {
if (isDemoMode) {
const handleChange = (event) => {
if (event.detail === boardId) onChange({ board_id: boardId });
};
const handleStorage = (event) => {
if (event.key === DEMO_STORAGE_KEY) onChange({ board_id: boardId });
};
let channel;
if (typeof BroadcastChannel !== 'undefined') {
channel = new BroadcastChannel(DEMO_CHANNEL_NAME);
channel.onmessage = (event) => {
if (event.data?.boardId === boardId) onChange({ board_id: boardId });
};
}
window.addEventListener('live-board-demo-change', handleChange);
window.addEventListener('storage', handleStorage);
onStatus?.({ connected: true, label: '本機示範同步', kind: 'demo' });
return () => {
channel?.close();
window.removeEventListener('live-board-demo-change', handleChange);
window.removeEventListener('storage', handleStorage);
};
}
const channel = supabase
.channel(`board:${boardId}`, { config: { broadcast: { self: false } } })
.on('broadcast', { event: 'board_changed' }, (payload) => {
if (!payload.payload?.board_id || payload.payload.board_id === boardId) onChange(payload.payload || { board_id: boardId });
})
.subscribe((status) => {
if (status === 'SUBSCRIBED') onStatus?.({ connected: true, label: '即時同步中', kind: 'live' });
else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
onStatus?.({ connected: false, label: '即時連線中斷,使用 HTTPS 備援', kind: 'fallback' });
}
});
return () => supabase.removeChannel(channel);
}
1import { BOARD_SLUG, isDemoMode } from './config.js';
2import { sortPosts } from './sorting.js';
3import { supabase } from './supabase.js';
4import { nowIso } from './utils.js';
6const DEMO_STORAGE_KEY = 'live-board-mvp-demo-state-v2';
7const DEMO_CHANNEL_NAME = 'live-board-mvp-demo-sync';
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));
90}
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);
124}
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);
134}
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 }));
147}
149function getDemoBoard(state, slug, includeInactive = false) {
150 return state.boards.find((board) => board.slug === slug && (includeInactive || board.is_active));
151}
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();
158}
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));
167}
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;
177}
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 };
199}
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 };
212}
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 || [];
228}
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 };
273}
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 });
292}
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;
309}
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;
334}
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);
340}
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;
355}
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;
402}
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;
429}
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 };
483}
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;
497}
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);
505}
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;
526}
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 || [];
537}
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);
577}