Countdown / index.html
Lashtw's picture
Upload index.html
2d6425f verified
Raw History Blame Contribute Delete
63.2 kB
<!DOCTYPE html>
<html lang="zh-TW">
<html lang="zh-TW"><head><script>window.huggingface={variables:{"SPACE_CREATOR_USER_ID":"675d88eddd3e9eeed66ba88f"}};</script><script>window.huggingface={variables:{"SPACE_CREATOR_USER_ID":"675d88eddd3e9eeed66ba88f"}};</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Classroom Focus OS - 班級旗艦極光看板</title>
<style>
:root {
--bg-color: #050b14;
--card-bg: rgba(15, 23, 42, 0.65);
--card-border: rgba(255, 255, 255, 0.12);
--card-shadow: 0 20px 40px rgba(0, 0, 0, 0.45);
--primary-text: #ffffff;
--secondary-text: #94a3b8;
--accent-color: #38bdf8;
--badge-bg: rgba(255, 255, 255, 0.08);
--progress-bg: rgba(255, 255, 255, 0.1);
}
body[data-theme="dark-slate"] {
--bg-color: #050b14;
--card-bg: rgba(15, 23, 42, 0.65);
--card-border: rgba(255, 255, 255, 0.12);
--primary-text: #ffffff;
--secondary-text: #94a3b8;
--accent-color: #38bdf8;
--badge-bg: rgba(255, 255, 255, 0.08);
--progress-bg: rgba(255, 255, 255, 0.1);
}
body[data-theme="aurora-fluid"] {
--bg-color: #021a14;
--card-bg: rgba(6, 78, 59, 0.45);
--card-border: rgba(52, 211, 153, 0.25);
--primary-text: #ffffff;
--secondary-text: #a7f3d0;
--accent-color: #34d399;
--badge-bg: rgba(52, 211, 153, 0.12);
--progress-bg: rgba(255, 255, 255, 0.12);
}
body[data-theme="star-meteor"] {
--bg-color: #030712;
--card-bg: rgba(17, 24, 39, 0.7);
--card-border: rgba(99, 102, 241, 0.25);
--primary-text: #f3f4f6;
--secondary-text: #9ca3af;
--accent-color: #818cf8;
--badge-bg: rgba(99, 102, 241, 0.15);
--progress-bg: rgba(255, 255, 255, 0.1);
}
body[data-theme="sunset-glow"] {
--bg-color: #1a0b1c;
--card-bg: rgba(74, 21, 62, 0.45);
--card-border: rgba(244, 114, 182, 0.25);
--primary-text: #ffffff;
--secondary-text: #fbcfe8;
--accent-color: #f472b6;
--badge-bg: rgba(244, 114, 182, 0.15);
--progress-bg: rgba(255, 255, 255, 0.1);
}
body[data-theme="gold-luxury"] {
--bg-color: #0a0a0a;
--card-bg: rgba(28, 25, 23, 0.75);
--card-border: rgba(234, 179, 8, 0.3);
--primary-text: #fef08a;
--secondary-text: #ca8a04;
--accent-color: #eab308;
--badge-bg: rgba(234, 179, 8, 0.15);
--progress-bg: rgba(255, 255, 255, 0.1);
}
body[data-theme="minimal-light"] {
--bg-color: #e2e8f0;
--card-bg: rgba(255, 255, 255, 0.88);
--card-border: rgba(203, 213, 225, 0.8);
--card-shadow: 0 15px 35px rgba(0, 0, 0, 0.08);
--primary-text: #0f172a;
--secondary-text: #64748b;
--accent-color: #2563eb;
--badge-bg: rgba(0, 0, 0, 0.06);
--progress-bg: rgba(0, 0, 0, 0.08);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans TC", sans-serif;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg-color);
color: var(--primary-text);
position: relative;
transition: background 0.4s ease;
touch-action: none;
}
#aurora-canvas {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 1.5s ease-in-out;
}
#aurora-canvas.active {
opacity: 1;
}
.top-bar {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
z-index: 10;
pointer-events: none;
width: 90%;
max-width: 900px;
}
.live-clock-line {
display: flex;
align-items: center;
gap: 12px;
}
.live-clock {
font-size: 2.2rem;
font-weight: 800;
letter-spacing: -0.5px;
color: var(--primary-text);
font-feature-settings: "tnum";
text-shadow: 0 4px 15px rgba(0,0,0,0.4);
}
.period-indicator {
font-size: 0.75rem;
padding: 3px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(4px);
letter-spacing: 0.5px;
}
.period-indicator.break {
background: rgba(52, 211, 153, 0.2);
color: #34d399;
border: 1px solid rgba(52, 211, 153, 0.4);
box-shadow: 0 0 12px rgba(52, 211, 153, 0.3);
}
.quote-capsule {
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 6px 20px;
border-radius: 999px;
font-size: 0.95rem;
color: var(--primary-text);
letter-spacing: 0.5px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
max-width: 100%;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
}
.quote-capsule::before { content: '❝ '; color: #38bdf8; font-weight: bold; }
.quote-capsule::after { content: ' ❞'; color: #38bdf8; font-weight: bold; }
#canvas-workspace {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
z-index: 2;
touch-action: none;
}
.board-card {
position: absolute;
background: var(--card-bg);
border: 1px solid var(--card-border);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: var(--card-shadow);
display: flex;
flex-direction: column;
overflow: hidden;
touch-action: none;
}
.board-card[data-cat="exam"] {
--theme-color: #38bdf8;
--num-color: #38bdf8;
--num-glow: rgba(56, 189, 248, 0.35);
}
.board-card[data-cat="sports"] {
--theme-color: #f97316;
--num-color: #fb923c;
--num-glow: rgba(249, 115, 22, 0.35);
}
.board-card[data-cat="event"] {
--theme-color: #ec4899;
--num-color: #f472b6;
--num-glow: rgba(236, 72, 153, 0.35);
}
.board-card[data-cat="life"] {
--theme-color: #10b981;
--num-color: #34d399;
--num-glow: rgba(16, 185, 129, 0.35);
}
.board-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 5px;
background: var(--theme-color);
z-index: 2;
}
.board-card.highlight {
border: 1.8px solid #fbbf24;
box-shadow: 0 16px 40px rgba(251, 191, 36, 0.28);
--theme-color: #fbbf24;
--num-color: #fbbf24;
--num-glow: rgba(251, 191, 36, 0.45);
}
.board-card.highlight::before {
background: #fbbf24;
height: 7px;
}
.card-inner {
width: 100%;
height: 100%;
padding: 20px 24px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 10px;
pointer-events: none;
}
.card-top {
display: flex;
flex-direction: column;
gap: 4px;
}
.card-header-line {
display: flex;
align-items: center;
gap: 10px;
}
.cat-icon {
width: 34px;
height: 34px;
border-radius: 9px;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-color);
flex-shrink: 0;
}
.cat-icon svg { width: 20px; height: 20px; }
.board-card.highlight .cat-icon {
background: rgba(251, 191, 36, 0.16);
}
.card-title {
font-size: 1.25rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--primary-text);
}
.card-target-date {
font-size: 0.85rem;
color: var(--secondary-text);
letter-spacing: 0.5px;
padding-left: 2px;
}
.card-center-metric {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 0;
}
.metric-number {
font-size: 4.5rem;
font-weight: 800;
line-height: 1;
letter-spacing: -2px;
font-feature-settings: "tnum";
color: var(--num-color);
text-shadow: 0 0 24px var(--num-glow);
}
.metric-unit {
font-size: 1.2rem;
color: var(--theme-color);
opacity: 0.85;
font-weight: 500;
}
.card-dynamic-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.progress-track {
width: 100%;
height: 6px;
background: var(--progress-bg);
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--theme-color);
border-radius: 999px;
transition: width 0.2s ease;
}
.metric-details {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
color: var(--secondary-text);
}
.card-badge-wrap {
display: flex;
justify-content: center;
}
.status-capsule {
font-size: 0.8rem;
padding: 4px 14px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--secondary-text);
font-weight: 500;
}
.board-card.highlight .status-capsule {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
.is-editing .board-card {
border: 1.5px dashed #38bdf8 !important;
cursor: grab;
}
.is-editing .board-card:active {
cursor: grabbing;
}
/* 放大把手並加強視覺指引 */
.resize-handle {
display: none;
position: absolute;
right: 0px;
bottom: 0px;
width: 42px;
height: 42px;
cursor: se-resize;
background: radial-gradient(circle at 75% 75%, rgba(56, 189, 248, 0.9) 0%, transparent 68%);
border-bottom-right-radius: 24px;
z-index: 20;
touch-action: none;
pointer-events: auto;
}
.is-editing .resize-handle {
display: block;
}
.edit-banner {
display: none;
position: fixed;
top: 16px;
right: 20px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid #38bdf8;
backdrop-filter: blur(8px);
padding: 8px 16px;
border-radius: 12px;
z-index: 1000;
align-items: center;
gap: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.is-editing .edit-banner {
display: flex;
}
.settings-trigger {
position: fixed;
bottom: 20px;
right: 20px;
width: 52px;
height: 52px;
border-radius: 50%;
background: rgba(15, 23, 42, 0.8);
border: 1.5px solid rgba(255, 255, 255, 0.25);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #94a3b8;
opacity: 0.4;
transition: all 0.2s;
z-index: 500;
touch-action: manipulation;
}
.settings-trigger:hover, .settings-trigger:active {
opacity: 1;
transform: rotate(30deg);
color: #fff;
}
.modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(12px);
z-index: 2000;
align-items: center;
justify-content: center;
}
.pin-modal-box {
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 20px;
padding: 24px 28px;
width: 320px;
text-align: center;
color: #fff;
box-shadow: 0 25px 50px rgba(0,0,0,0.6);
}
.pin-dots-display {
display: flex;
justify-content: center;
gap: 12px;
margin: 16px 0 20px;
height: 24px;
align-items: center;
}
.pin-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #64748b;
background: transparent;
transition: all 0.15s ease;
}
.pin-dot.filled {
background: #38bdf8;
border-color: #38bdf8;
box-shadow: 0 0 10px rgba(56, 189, 248, 0.6);
transform: scale(1.15);
}
.pin-dot.error {
background: #ef4444;
border-color: #ef4444;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.6);
}
.pin-keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 16px;
}
.pin-btn {
height: 52px;
font-size: 1.35rem;
font-weight: 600;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(15, 23, 42, 0.7);
color: #f8fafc;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
transition: all 0.15s ease;
}
.pin-btn:hover {
background: rgba(56, 189, 248, 0.2);
border-color: #38bdf8;
}
.pin-btn:active {
transform: scale(0.95);
background: #38bdf8;
color: #0f172a;
}
.pin-btn.action-btn {
font-size: 1rem;
color: #94a3b8;
}
.btn {
padding: 8px 16px;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
touch-action: manipulation;
}
.btn-primary { background: #38bdf8; color: #0f172a; }
.btn-secondary { background: #334155; color: #fff; }
.btn-success { background: #10b981; color: #fff; }
.btn-danger { background: #ef4444; color: #fff; }
.admin-drawer {
display: none;
position: fixed;
top: 0;
right: 0;
width: 580px;
max-width: 94vw;
height: 100vh;
background: #0f172a;
border-left: 1px solid #334155;
box-shadow: -15px 0 40px rgba(0,0,0,0.6);
z-index: 2100;
flex-direction: column;
color: #e2e8f0;
}
.drawer-header {
padding: 20px;
border-bottom: 1px solid #1e293b;
display: flex;
justify-content: space-between;
align-items: center;
}
.drawer-content {
padding: 20px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 18px;
}
.setting-block {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.setting-block h4 {
font-size: 0.95rem;
color: #38bdf8;
}
.item-row {
background: #090d16;
border: 1px solid #334155;
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.item-inputs {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 8px;
}
.setting-block input, .setting-block select, .setting-block textarea {
background: #090d16;
border: 1px solid #334155;
color: #fff;
padding: 7px 10px;
border-radius: 6px;
font-size: 0.85rem;
}
</style>
</head>
<body data-theme="dark-slate" class="is-editing">
<canvas id="aurora-canvas" width="1600" height="681" class=""></canvas>
<div class="top-bar">
<div class="live-clock-line">
<div class="live-clock" id="liveClock">07:40:38</div>
<div class="period-indicator" id="periodIndicator">課堂專注模式</div>
</div>
<div class="quote-capsule" id="dailyQuoteText">一天弄懂一題,一百天就多一百題。</div>
</div>
<div id="canvas-workspace"><div class="board-card " id="card-1" data-cat="exam" style="left: 956.083px; top: 110.417px; width: 274px; height: 292.417px;">
<div class="card-inner">
<div class="card-top">
<div class="card-header-line">
<div class="cat-icon"><svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg></div>
<div class="card-title">第一次定期評量</div>
</div>
<div class="card-target-date">2026.10.14</div>
</div>
<div class="card-center-metric">
<span class="metric-number" style="font-size: 4.281248092651367rem;">21</span>
<span class="metric-unit">天</span>
</div>
<div class="card-dynamic-body" style="display: none;">
<div class="progress-track">
<div class="progress-fill" style="width: 65%;"></div>
</div>
<div class="metric-details">
<span>備考準備期進度:65%</span>
<span>約合 <b>3.0</b> 週</span>
</div>
</div>
<div class="card-badge-wrap">
<div class="status-capsule">倒數 21 天</div>
</div>
</div>
<div class="resize-handle"></div>
</div><div class="board-card " id="card-2" data-cat="sports" style="left: 957.417px; top: 405.333px; width: 273.417px; height: 237.833px;">
<div class="card-inner">
<div class="card-top">
<div class="card-header-line">
<div class="cat-icon"><svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"></path><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"></path><path d="M4 22h16"></path><path d="M10 14.66V17c0 .55-.45 1-1 1H7v4h10v-4h-2c-.55 0-1-.45-1-1v-2.34c3.08-.63 5.4-3.3 5.4-6.66V4H4v4c0 3.36 2.32 6.03 5.4 6.66z"></path></svg></div>
<div class="card-title">校慶運動大會</div>
</div>
<div class="card-target-date">2026.11.14</div>
</div>
<div class="card-center-metric">
<span class="metric-number" style="font-size: 4.272134780883789rem;">52</span>
<span class="metric-unit">天</span>
</div>
<div class="card-dynamic-body" style="display: none;">
<div class="progress-track">
<div class="progress-fill" style="width: 42%;"></div>
</div>
<div class="metric-details">
<span>備考準備期進度:42%</span>
<span>約合 <b>7.4</b> 週</span>
</div>
</div>
<div class="card-badge-wrap">
<div class="status-capsule">倒數 52 天</div>
</div>
</div>
<div class="resize-handle"></div>
</div><div class="board-card highlight" id="card-3" data-cat="exam" style="left: 331.75px; top: 110.25px; width: 314.083px; height: 537.667px;">
<div class="card-inner">
<div class="card-top">
<div class="card-header-line">
<div class="cat-icon"><svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg></div>
<div class="card-title">國中教育會考</div>
</div>
<div class="card-target-date">2027.05.15</div>
</div>
<div class="card-center-metric">
<span class="metric-number" style="font-size: 4.9075517654418945rem;">234</span>
<span class="metric-unit">天</span>
</div>
<div class="card-dynamic-body" style="display: flex;">
<div class="progress-track">
<div class="progress-fill" style="width: 22%;"></div>
</div>
<div class="metric-details">
<span>備考準備期進度:22%</span>
<span>約合 <b>33.4</b> 週</span>
</div>
</div>
<div class="card-badge-wrap">
<div class="status-capsule">倒數 234 天</div>
</div>
</div>
<div class="resize-handle"></div>
</div><div class="board-card highlight" id="card-1789777581106" data-cat="exam" style="left: 655.25px; top: 110.417px; width: 291px; height: 536px;">
<div class="card-inner">
<div class="card-top">
<div class="card-header-line">
<div class="cat-icon"><svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg></div>
<div class="card-title">全縣模擬考(1-4冊)</div>
</div>
<div class="card-target-date">2026.12.23</div>
</div>
<div class="card-center-metric">
<span class="metric-number" style="font-size: 4.546875rem;">91</span>
<span class="metric-unit">天</span>
</div>
<div class="card-dynamic-body" style="display: flex;">
<div class="progress-track">
<div class="progress-fill" style="width: 9%;"></div>
</div>
<div class="metric-details">
<span>備考準備期進度:9%</span>
<span>約合 <b>13.0</b> 週</span>
</div>
</div>
<div class="card-badge-wrap">
<div class="status-capsule">倒數 91 天</div>
</div>
</div>
<div class="resize-handle"></div>
</div></div>
<!-- 編輯狀態控制列 -->
<div class="edit-banner" id="editBanner">
<span style="font-size:0.85rem; color:#38bdf8;">✦ 自由排版中:拖曳卡片或縮放邊角</span>
<button class="btn btn-secondary" onclick="randomizeQuote()">隨機金句</button>
<button class="btn btn-secondary" onclick="openAdminDrawer()">作息與設定</button>
<button class="btn btn-success" onclick="exitEditMode()">完成並鎖定</button>
</div>
<!-- 齒輪解鎖按鈕 -->
<div class="settings-trigger" onclick="openPinModal()" title="開啟看板設定">
<svg width="26" height="26" fill="currentColor" viewBox="0 0 24 24">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path>
</svg>
</div>
<!-- 螢幕數位按鍵鎖 -->
<div class="modal-backdrop" id="pinModal" style="display: none;">
<div class="pin-modal-box">
<h3>管理員驗證</h3>
<p style="font-size:0.8rem; color:#94a3b8; margin-top:4px;">請點擊螢幕按鈕輸入 PIN 碼 (902)</p>
<div class="pin-dots-display" id="pinDots">
<div class="pin-dot filled"></div>
<div class="pin-dot filled"></div>
<div class="pin-dot filled"></div>
</div>
<div class="pin-keypad">
<button class="pin-btn" onclick="pressKey('1')">1</button>
<button class="pin-btn" onclick="pressKey('2')">2</button>
<button class="pin-btn" onclick="pressKey('3')">3</button>
<button class="pin-btn" onclick="pressKey('4')">4</button>
<button class="pin-btn" onclick="pressKey('5')">5</button>
<button class="pin-btn" onclick="pressKey('6')">6</button>
<button class="pin-btn" onclick="pressKey('7')">7</button>
<button class="pin-btn" onclick="pressKey('8')">8</button>
<button class="pin-btn" onclick="pressKey('9')">9</button>
<button class="pin-btn action-btn" onclick="clearKey()">C</button>
<button class="pin-btn" onclick="pressKey('0')">0</button>
<button class="pin-btn action-btn" onclick="backspaceKey()">⌫</button>
</div>
<button class="btn btn-secondary" style="width:100%;" onclick="closePinModal()">取消</button>
</div>
</div>
<!-- 後台側拉抽屜 -->
<div class="admin-drawer" id="adminDrawer" style="display: flex;">
<div class="drawer-header">
<h3>看板與上下課作息設定</h3>
<button class="btn btn-secondary" onclick="closeAdminDrawer()">關閉</button>
</div>
<div class="drawer-content">
<div class="setting-block" style="border: 1px solid #38bdf8;">
<h4>永久固化排版 (解決重開機重設問題)</h4>
<p style="font-size:0.8rem; color:#94a3b8;">排版完成後,點擊下方「固化為全新預設檔」,下載覆蓋原本的 `index.html`,重開機保證 100% 永不跑位。</p>
<div style="display:flex; flex-direction:column; gap:8px;">
<button class="btn btn-success" style="padding:10px; font-size:0.9rem;" onclick="exportAsHardcodedHtml()">⭐ 固化為全新預設檔 (下載 index.html)</button>
<div style="display:flex; gap:8px;">
<button class="btn btn-secondary" style="flex:1;" onclick="downloadConfigFile()">💾 備份設定 (.json)</button>
<label class="btn btn-primary" style="flex:1; text-align:center; cursor:pointer; margin:0; line-height:1.2;">
📂 匯入設定檔
<input type="file" id="configFileUploader" accept=".json" style="display:none;" onchange="importConfigFile(event)">
</label>
</div>
</div>
</div>
<div class="setting-block">
<h4>視覺風格主題 (6款風格)</h4>
<select id="themeSelect" onchange="changeTheme(this.value)">
<option value="dark-slate">深邃板岩灰 (Dark Slate)</option>
<option value="aurora-fluid">極光流體 (Aurora Glow)</option>
<option value="star-meteor">星夜流星 (Starry Meteor)</option>
<option value="sunset-glow">落日餘暉 (Sunset Glow)</option>
<option value="gold-luxury">黑金尊爵 (Gold Luxury)</option>
<option value="minimal-light">晨光清透 (Minimal Light)</option>
</select>
</div>
<div class="setting-block">
<h4>作息時間表 (下課自動啟動極光流動)</h4>
<p style="font-size:0.8rem; color:#94a3b8;">每行設定一節課堂(格式:開始-結束,例如 08:15-09:00),其餘時間自動視為下課。</p>
<textarea id="scheduleInput" rows="6" style="font-family:monospace; font-size:0.85rem; resize:vertical;"></textarea>
</div>
<div class="setting-block">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h4>倒數項目資料庫 (可複選多個高亮)</h4>
<button class="btn btn-primary" onclick="addNewEvent()">+ 新增卡片</button>
</div>
<div id="editorItemList" style="display:flex; flex-direction:column; gap:10px;"><div class="item-row">
<div class="item-inputs">
<input type="text" value="第一次定期評量" placeholder="名稱" onchange="appState.cards[0].title=this.value">
<input type="date" value="2026-10-14" onchange="appState.cards[0].date=this.value">
</div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<select onchange="appState.cards[0].cat=this.value; renderBoard();">
<option value="exam" selected="">學業考試 (書本)</option>
<option value="sports">運動競賽 (獎盃)</option>
<option value="event">校慶活動 (旗幟)</option>
<option value="life">常態生活 (時鐘)</option>
</select>
<label style="font-size:0.8rem; color:#cbd5e1; display:flex; align-items:center; gap:4px; cursor:pointer;">
<input type="checkbox" onchange="toggleCardHighlight(0, this.checked)"> 重要高亮
</label>
<button class="btn btn-danger" style="padding:4px 8px;" onclick="removeCard(0)">刪除</button>
</div>
</div><div class="item-row">
<div class="item-inputs">
<input type="text" value="校慶運動大會" placeholder="名稱" onchange="appState.cards[1].title=this.value">
<input type="date" value="2026-11-14" onchange="appState.cards[1].date=this.value">
</div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<select onchange="appState.cards[1].cat=this.value; renderBoard();">
<option value="exam">學業考試 (書本)</option>
<option value="sports" selected="">運動競賽 (獎盃)</option>
<option value="event">校慶活動 (旗幟)</option>
<option value="life">常態生活 (時鐘)</option>
</select>
<label style="font-size:0.8rem; color:#cbd5e1; display:flex; align-items:center; gap:4px; cursor:pointer;">
<input type="checkbox" onchange="toggleCardHighlight(1, this.checked)"> 重要高亮
</label>
<button class="btn btn-danger" style="padding:4px 8px;" onclick="removeCard(1)">刪除</button>
</div>
</div><div class="item-row">
<div class="item-inputs">
<input type="text" value="國中教育會考" placeholder="名稱" onchange="appState.cards[2].title=this.value">
<input type="date" value="2027-05-15" onchange="appState.cards[2].date=this.value">
</div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<select onchange="appState.cards[2].cat=this.value; renderBoard();">
<option value="exam" selected="">學業考試 (書本)</option>
<option value="sports">運動競賽 (獎盃)</option>
<option value="event">校慶活動 (旗幟)</option>
<option value="life">常態生活 (時鐘)</option>
</select>
<label style="font-size:0.8rem; color:#cbd5e1; display:flex; align-items:center; gap:4px; cursor:pointer;">
<input type="checkbox" checked="" onchange="toggleCardHighlight(2, this.checked)"> 重要高亮
</label>
<button class="btn btn-danger" style="padding:4px 8px;" onclick="removeCard(2)">刪除</button>
</div>
</div><div class="item-row">
<div class="item-inputs">
<input type="text" value="全縣模擬考(1-4冊)" placeholder="名稱" onchange="appState.cards[3].title=this.value">
<input type="date" value="2026-12-23" onchange="appState.cards[3].date=this.value">
</div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<select onchange="appState.cards[3].cat=this.value; renderBoard();">
<option value="exam" selected="">學業考試 (書本)</option>
<option value="sports">運動競賽 (獎盃)</option>
<option value="event">校慶活動 (旗幟)</option>
<option value="life">常態生活 (時鐘)</option>
</select>
<label style="font-size:0.8rem; color:#cbd5e1; display:flex; align-items:center; gap:4px; cursor:pointer;">
<input type="checkbox" checked="" onchange="toggleCardHighlight(3, this.checked)"> 重要高亮
</label>
<button class="btn btn-danger" style="padding:4px 8px;" onclick="removeCard(3)">刪除</button>
</div>
</div></div>
</div>
</div>
<div style="padding:16px; border-top:1px solid #1e293b; display:flex; justify-content:flex-end;">
<button class="btn btn-success" onclick="saveAllSettings()">儲存並發布</button>
</div>
</div>
<script>
const QUOTES = [
"最好的時機是還沒開始之前,第二好的時機,就是你按下開始的這一秒。",
"你不需要準備好才開始,你會因為開始了才慢慢變好。",
"遲到三天的練習,還是贏過準時放棄。",
"現在做的一點點,都好過明天打算做的很多。",
"你不是起步太晚,你只是還沒起步。",
"想清楚很好,但先做一點點更好。",
"你缺的從來不是計畫,是今天先做的那一步。",
"不用等到有把握,邊做邊有把握。",
"每個「以後再說」,都是在跟未來的自己借錢。",
"開始的那三秒最痛,過了就順了。",
"決定要做的時候,最難的部分就已經結束了。",
"種樹最好的時間是二十年前,第二好的時間是今天。",
"認真的最好時間是三年前,第二好的時間就是現在。",
"不用完美開場,先開場才有辦法變完美。",
"明天不會比今天更適合,只會比今天更晚。",
"先踏出第一步,路會在腳下慢慢長出來。",
"你等的不是時機,是勇氣。",
"做,比想,更快讓你變強。",
"起步晚的人只要不停,一樣會到。",
"現在開始,永遠是最不晚的選擇。",
"決定開始需要勇氣,走下去只需要習慣。",
"進步不是每天都看得出來,而是某天回頭,發現已經走了很遠。",
"沒有一天是白費的,只是有些天的成果晚一點才到帳。",
"撐住,不是因為你不累,是因為你知道停下來會更累。",
"慢沒關係,別停;停沒關係,別回頭。",
"每天做一點的人,最後會贏過偶爾拼命的人。",
"撐下去不是因為很強,是因為還沒打算放棄。",
"今天的進度很小,但它不會消失。",
"你不是沒進步,你只是還在進步的路上。",
"堅持的秘訣不是意志力,是把它變成每天要做的事。",
"別人看到的是成果,只有你知道自己撐了多久。",
"一天一天來,累積不會騙人。",
"只要還在走,就不算失敗。",
"你今天的堅持,是明天輕鬆的原因。",
"累了可以休息,但別順便放棄。",
"大目標很遠,下一步很近,先跨下一步。",
"穩定比猛烈更難,也更有用。",
"你已經撐到今天了,再多撐一天不難。",
"放棄的念頭會來,讓它來,但別聽它的。",
"走到最後的人,往往不是最快的,是沒停的。",
"錯的這一題,正在教你對的那一題怎麼寫。",
"跌倒的次數,是你在嘗試的證明,不是你不行的證明。",
"會怕,代表這件事對你有意義;會做,代表你已經比昨天更強。",
"考差了不是句點,是這次的逗號。",
"你不需要每題都對,你只需要每題都弄懂。",
"寫錯的地方,才是你真正會變強的地方。",
"不會,只是「還沒會」,不是「永遠不會」。",
"每次卡住,都是你的能力在長大前先痛一下。",
"分數會騙人,努力不會。",
"被打倒不可怕,怕的是你相信了「我不行」。",
"錯誤是最好用的老師,因為它只教你缺的那一塊。",
"摔過的地方,下次你就會記得繞過去。",
"你現在不熟的,練久就會變熟。",
"失敗只說明這次的方法不對,不說明你不行。",
"敢再試一次的人,就已經比昨天勇敢。",
"做錯比不做有價值,至少你知道路在哪。",
"每一題訂正,都是在替自己補一個洞。",
"不會的問題問出口,它就開始變成你的。",
"進步常常藏在最不想面對的那個錯誤裡。",
"你不需要一次就學會,你只需要沒停下來學。",
"你現在的每一天,都是在替考場上的自己鋪路。",
"別想一次追完所有進度,只要今天比昨天多懂一點。",
"緊張是因為你在乎,把在乎換成多做一題。",
"會考不是審判你有多聰明,是檢查你努力了多少。",
"現在的安靜努力,會變成放榜那天的大聲歡呼。",
"你現在背的每個公式,都是考場上的武器。",
"一天弄懂一題,一百天就多一百題。",
"焦慮不會讓你考更好,多做一題會。",
"撐過這段無聊又辛苦的日子,你會感謝現在的自己。",
"會考只考一次,但你為它努力的樣子會跟你很久。",
"會考比的是誰準備得比較久,不是誰比較聰明。",
"你現在多撐的每一分鐘,考場上都算數。",
"現在讀不下去的時候,想想放榜那天想看到的自己。",
"把大範圍拆小,今天只要顧好今天這頁。",
"你不需要跟全班比,你只需要比昨天的自己多會一點。",
"每張寫過的複習卷,都在幫你少錯一題。",
"現在忍一下,以後就少後悔一次。",
"考前的努力不會消失,它只是在等放榜那天出現。",
"別問來不來得及,先問今天做了多少。",
"你離目標的距離,等於你今天願意多做的題數。",
"你不必跑贏所有人,只要跑贏上次那個自己。",
"練習時流的汗,會在比賽時替你說話。",
"班級的第一名,是每個人多撐一步換來的。",
"名次會過去,你有沒有盡力,你自己會記得一輩子。",
"上場前先深呼吸,你已經準備得比你想的更充分了。",
"你追的不是對手,是昨天那個想放棄的自己。",
"每一次練習,都是替比賽那天多存一點底氣。",
"全班一起撐,就會比一個人撐得久。",
"輸贏一分鐘,努力一學期,你懂哪個比較重。",
"站上場的那一刻,你已經贏過還沒開始的人。",
"比賽只有幾秒,但你有好幾個星期可以準備。",
"今天的練習,是為了讓你那天不會後悔。",
"緊張就深呼吸,把緊張變成衝出去的力量。",
"一個人跑得快,一群人跑得遠。",
"破自己的紀錄,比拿別人的名次更值得。",
"現在多練一趟,比賽就少喘一口氣。",
"輸了不用怕,怕的是你沒盡力就說算了。",
"你為班級流的汗,全班都會記得。",
"起跑槍響前,先告訴自己:我準備好了。",
"盡力的人,站在哪裡都閃亮。"
];
const ICONS = {
exam: `<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>`,
sports: `<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"></path><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"></path><path d="M4 22h16"></path><path d="M10 14.66V17c0 .55-.45 1-1 1H7v4h10v-4h-2c-.55 0-1-.45-1-1v-2.34c3.08-.63 5.4-3.3 5.4-6.66V4H4v4c0 3.36 2.32 6.03 5.4 6.66z"></path></svg>`,
event: `<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path><line x1="4" y1="22" x2="4" y2="15"></line></svg>`,
life: `<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>`
};
const DEFAULT_SCHEDULE = [
"08:15-09:00",
"09:10-09:55",
"10:10-10:55",
"11:05-11:50",
"13:10-13:55",
"14:05-14:50",
"15:05-15:50",
"16:00-16:45"
];
/*START_DEFAULT_DATA*/
const DEFAULT_DATA = {
"theme": "dark-slate",
"schedule": [
"07:30-08:00",
"08:10-08:55",
"09:05-09:50",
"10:10-10:55",
"11:05-11:50",
"13:10-13:55",
"14:05-14:50",
"15:05-15:50",
"16:00-16:45",
"17:00-18:00"
],
"cards": [
{
"id": "1",
"title": "第一次定期評量",
"date": "2026-10-14",
"cat": "exam",
"highlight": false,
"x": 956.0833740234375,
"y": 110.41667175292969,
"w": 273.9998779296875,
"h": 292.41668701171875,
"totalSpan": 60
},
{
"id": "2",
"title": "校慶運動大會",
"date": "2026-11-14",
"cat": "sports",
"highlight": false,
"x": 957.4166259765625,
"y": 405.33331298828125,
"w": 273.4166259765625,
"h": 237.83331298828125,
"totalSpan": 90
},
{
"id": "3",
"title": "國中教育會考",
"date": "2027-05-15",
"cat": "exam",
"highlight": true,
"x": 331.75,
"y": 110.25,
"w": 314.08331298828125,
"h": 537.6666870117188,
"totalSpan": 300
},
{
"id": "1789777581106",
"title": "全縣模擬考(1-4冊)",
"date": "2026-12-23",
"cat": "exam",
"highlight": true,
"x": 655.2500610351562,
"y": 110.41667175292969,
"w": 291,
"h": 536,
"totalSpan": 100
}
]
};
/*END_DEFAULT_DATA*/
const STORAGE_KEY = "classroom_focus_master_data";
let appState = loadInitialData();
let isEditing = false;
let isClassBreak = false;
let currentPin = "";
function openDB() {
return new Promise((resolve) => {
const req = indexedDB.open("ClassroomFocusDB", 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains("settings")) {
db.createObjectStore("settings");
}
};
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = () => resolve(null);
});
}
async function saveToIndexedDB(data) {
const db = await openDB();
if (!db) return;
const tx = db.transaction("settings", "readwrite");
tx.objectStore("settings").put(data, "master_data");
}
async function loadFromIndexedDB() {
const db = await openDB();
if (!db) return null;
return new Promise((resolve) => {
const tx = db.transaction("settings", "readonly");
const req = tx.objectStore("settings").get("master_data");
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
}
function loadInitialData() {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : DEFAULT_DATA;
}
async function initStorageSync() {
const idbData = await loadFromIndexedDB();
if (idbData && idbData.cards) {
appState = idbData;
saveAppData();
renderBoard();
document.body.setAttribute("data-theme", appState.theme);
}
}
initStorageSync();
function saveAppData() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(appState));
saveToIndexedDB(appState);
}
function exportAsHardcodedHtml() {
let currentHtml = document.documentElement.outerHTML;
const targetDataString = JSON.stringify(appState, null, 2);
const regex = /\/\*START_DEFAULT_DATA\*\/[\s\S]*?\/\*END_DEFAULT_DATA\*\//;
const replacement = `/*START_DEFAULT_DATA*/\n const DEFAULT_DATA = ${targetDataString};\n /*END_DEFAULT_DATA*/`;
const newHtml = "<!DOCTYPE html>\n<html lang=\"zh-TW\">\n" + currentHtml.replace(regex, replacement) + "\n</html>";
const blob = new Blob([newHtml], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `index.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
alert("✓ 固化成功!請用剛下載的 `index.html` 直接覆蓋原檔,開機將永久預設此版面。");
}
function openPinModal() {
currentPin = "";
updatePinDots();
document.getElementById("pinModal").style.display = "flex";
}
function closePinModal() {
document.getElementById("pinModal").style.display = "none";
currentPin = "";
}
function pressKey(num) {
if (currentPin.length < 3) {
currentPin += num;
updatePinDots();
if (currentPin.length === 3) setTimeout(verifyPinCode, 150);
}
}
function backspaceKey() {
if (currentPin.length > 0) {
currentPin = currentPin.slice(0, -1);
updatePinDots();
}
}
function clearKey() {
currentPin = "";
updatePinDots();
}
function updatePinDots(isError = false) {
const dots = document.querySelectorAll(".pin-dot");
dots.forEach((dot, idx) => {
dot.className = "pin-dot";
if (isError) dot.classList.add("error");
else if (idx < currentPin.length) dot.classList.add("filled");
});
}
function verifyPinCode() {
if (currentPin === "902") {
closePinModal();
enterEditMode();
} else {
updatePinDots(true);
setTimeout(() => {
currentPin = "";
updatePinDots();
}, 500);
}
}
function downloadConfigFile() {
const fullPackage = {
theme: appState.theme,
schedule: appState.schedule,
cards: appState.cards
};
const jsonStr = JSON.stringify(fullPackage, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `classroom-dashboard-config.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function importConfigFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
applyJsonConfig(e.target.result);
};
reader.readAsText(file);
event.target.value = "";
}
function applyJsonConfig(jsonString) {
try {
const parsed = JSON.parse(jsonString);
if (parsed.cards && parsed.theme) {
appState.theme = parsed.theme;
appState.cards = parsed.cards;
if (parsed.schedule && Array.isArray(parsed.schedule)) {
appState.schedule = parsed.schedule;
}
saveAppData();
document.body.setAttribute("data-theme", appState.theme);
document.getElementById("themeSelect").value = appState.theme;
renderBoard();
updateClock();
closeAdminDrawer();
alert("✓ 設定已成功匯入!所有排版、顏色與重點標記已即刻生效。");
} else {
alert("設定檔格式不符。");
}
} catch (e) {
alert("JSON 解析失敗。");
}
}
function updateDailyQuote() {
const now = new Date();
const start = new Date(now.getFullYear(), 0, 0);
const diff = now - start;
const dayOfYear = Math.floor(diff / (1000 * 60 * 60 * 24));
const quoteIndex = dayOfYear % QUOTES.length;
document.getElementById("dailyQuoteText").textContent = QUOTES[quoteIndex];
}
function randomizeQuote() {
const randIdx = Math.floor(Math.random() * QUOTES.length);
document.getElementById("dailyQuoteText").textContent = QUOTES[randIdx];
}
function changeTheme(t) {
appState.theme = t;
document.body.setAttribute("data-theme", t);
}
function renderBoard() {
document.body.setAttribute("data-theme", appState.theme);
const ws = document.getElementById("canvas-workspace");
ws.innerHTML = "";
const today = new Date();
today.setHours(0,0,0,0);
appState.cards.forEach(card => {
const target = new Date(card.date + "T00:00:00");
const diffDays = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
const displayDays = diffDays >= 0 ? diffDays : 0;
const badgeText = diffDays > 0 ? `倒數 ${displayDays} 天` : (diffDays === 0 ? "🔥 就是今天" : "已圓滿結束");
const totalSpan = card.totalSpan || 100;
const progressPct = Math.max(0, Math.min(100, Math.round(((totalSpan - displayDays) / totalSpan) * 100)));
const weeksLeft = (displayDays / 7).toFixed(1);
const el = document.createElement("div");
el.className = `board-card ${card.highlight ? 'highlight' : ''}`;
el.id = `card-${card.id}`;
el.setAttribute("data-cat", card.cat);
el.style.left = `${card.x}px`;
el.style.top = `${card.y}px`;
el.style.width = `${card.w}px`;
el.style.height = `${card.h}px`;
const isTall = card.h >= 360;
const fontScale = Math.min(card.w / 4, card.h / (isTall ? 3.8 : 2.5));
const fontSizeRem = Math.max(2.8, Math.min(fontScale / 16, 7.5));
el.innerHTML = `
<div class="card-inner">
<div class="card-top">
<div class="card-header-line">
<div class="cat-icon">${ICONS[card.cat] || ICONS.life}</div>
<div class="card-title">${card.title}</div>
</div>
<div class="card-target-date">${card.date.replace(/-/g, '.')}</div>
</div>
<div class="card-center-metric">
<span class="metric-number" style="font-size: ${fontSizeRem}rem;">${displayDays}</span>
<span class="metric-unit">天</span>
</div>
<div class="card-dynamic-body" style="display: ${isTall ? 'flex' : 'none'};">
<div class="progress-track">
<div class="progress-fill" style="width: ${progressPct}%;"></div>
</div>
<div class="metric-details">
<span>備考準備期進度:${progressPct}%</span>
<span>約合 <b>${weeksLeft}</b> 週</span>
</div>
</div>
<div class="card-badge-wrap">
<div class="status-capsule">${badgeText}</div>
</div>
</div>
<div class="resize-handle"></div>
`;
attachTouchAndPointerEvents(el, card);
ws.appendChild(el);
});
}
// ========================================================
// 大屏觸控與滑鼠拖曳/縮放核心 (修復卡頓:拖動中不摧毀 DOM)
// ========================================================
function attachTouchAndPointerEvents(cardEl, cardData) {
const resizeHandle = cardEl.querySelector('.resize-handle');
// 1. 卡片移動拖曳
cardEl.addEventListener('pointerdown', (e) => {
if (!isEditing) return;
if (e.target === resizeHandle || e.target.classList.contains('resize-handle')) return;
e.preventDefault();
cardEl.setPointerCapture(e.pointerId);
let startX = e.clientX;
let startY = e.clientY;
let initLeft = cardEl.offsetLeft;
let initTop = cardEl.offsetTop;
cardEl.style.zIndex = 100;
function onPointerMove(ev) {
if (ev.pointerId !== e.pointerId) return;
ev.preventDefault();
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
const newX = Math.max(0, Math.min(window.innerWidth - cardEl.offsetWidth, initLeft + dx));
const newY = Math.max(0, Math.min(window.innerHeight - cardEl.offsetHeight, initTop + dy));
cardEl.style.left = `${newX}px`;
cardEl.style.top = `${newY}px`;
cardData.x = newX;
cardData.y = newY;
}
function onPointerUp(ev) {
if (ev.pointerId !== e.pointerId) return;
try { cardEl.releasePointerCapture(e.pointerId); } catch(err) {}
cardEl.style.zIndex = '';
cardEl.removeEventListener('pointermove', onPointerMove);
cardEl.removeEventListener('pointerup', onPointerUp);
cardEl.removeEventListener('pointercancel', onPointerUp);
saveAppData();
}
cardEl.addEventListener('pointermove', onPointerMove);
cardEl.addEventListener('pointerup', onPointerUp);
cardEl.addEventListener('pointercancel', onPointerUp);
});
// 2. 右下角角標拉伸縮放 (流暢優化:即時修改樣式,放開才全面儲存)
resizeHandle.addEventListener('pointerdown', (e) => {
if (!isEditing) return;
e.stopPropagation();
e.preventDefault();
resizeHandle.setPointerCapture(e.pointerId);
let startX = e.clientX;
let startY = e.clientY;
let startW = cardEl.offsetWidth;
let startH = cardEl.offsetHeight;
const metricNum = cardEl.querySelector('.metric-number');
const dynamicBody = cardEl.querySelector('.card-dynamic-body');
function onResizeMove(ev) {
if (ev.pointerId !== e.pointerId) return;
ev.preventDefault();
// 直覺計算全新寬度與高度
const newW = Math.max(240, startW + (ev.clientX - startX));
const newH = Math.max(180, startH + (ev.clientY - startY));
// 直接操作當前卡片樣式,不中斷事件流
cardEl.style.width = `${newW}px`;
cardEl.style.height = `${newH}px`;
cardData.w = newW;
cardData.h = newH;
// 動態平滑調整大數字字級
const isTall = newH >= 360;
const fontScale = Math.min(newW / 4, newH / (isTall ? 3.8 : 2.5));
const fontSizeRem = Math.max(2.8, Math.min(fontScale / 16, 7.5));
if (metricNum) metricNum.style.fontSize = `${fontSizeRem}rem`;
// 動態顯示/隱藏進度條
if (dynamicBody) dynamicBody.style.display = isTall ? 'flex' : 'none';
}
function onResizeUp(ev) {
if (ev.pointerId !== e.pointerId) return;
try { resizeHandle.releasePointerCapture(e.pointerId); } catch(err) {}
resizeHandle.removeEventListener('pointermove', onResizeMove);
resizeHandle.removeEventListener('pointerup', onResizeUp);
resizeHandle.removeEventListener('pointercancel', onResizeUp);
// 手指放開時,保存數據並重新校準極光焦點
saveAppData();
renderBoard();
}
resizeHandle.addEventListener('pointermove', onResizeMove);
resizeHandle.addEventListener('pointerup', onResizeUp);
resizeHandle.addEventListener('pointercancel', onResizeUp);
});
}
// 極光引擎 (多重高亮焦點光暈)
const canvas = document.getElementById("aurora-canvas");
const ctx = canvas.getContext("2d");
let animId = null;
let step = 0;
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
function renderAuroraWaves() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
step += 0.008;
const highlightCards = appState.cards.filter(c => c.highlight);
const primaryHighlight = highlightCards[0];
const focalX = primaryHighlight ? (primaryHighlight.x + primaryHighlight.w / 2) : canvas.width * 0.7;
const focalY = primaryHighlight ? (primaryHighlight.y + primaryHighlight.h / 2) : canvas.height * 0.4;
const colors = [
{ r: 16, g: 185, b: 129, a: 0.18 },
{ r: 56, g: 189, b: 248, a: 0.22 },
{ r: 168, g: 85, b: 247, a: 0.16 }
];
colors.forEach((col, idx) => {
ctx.beginPath();
const baseOffset = idx * 1.5;
const waveHeight = canvas.height * 0.35;
ctx.moveTo(0, waveHeight);
for (let x = 0; x <= canvas.width; x += 20) {
const distToFocus = Math.abs(x - focalX) / canvas.width;
const pullFactor = Math.sin((x / canvas.width) * Math.PI) * (1 - distToFocus * 0.5);
const y = waveHeight +
Math.sin(x * 0.003 + step + baseOffset) * 60 +
Math.cos(x * 0.0015 - step) * 40 +
(pullFactor * 45);
ctx.lineTo(x, y);
}
ctx.lineTo(canvas.width, 0);
ctx.lineTo(0, 0);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, focalX, focalY);
grad.addColorStop(0, `rgba(${col.r}, ${col.g}, ${col.b}, 0)`);
grad.addColorStop(0.5, `rgba(${col.r}, ${col.g}, ${col.b}, ${col.a})`);
grad.addColorStop(1, `rgba(${col.r}, ${col.g}, ${col.b}, 0)`);
ctx.fillStyle = grad;
ctx.filter = "blur(35px)";
ctx.fill();
ctx.filter = "none";
});
highlightCards.forEach(c => {
const cx = c.x + c.w / 2;
const cy = c.y + c.h / 2;
const radGrad = ctx.createRadialGradient(cx, cy, 30, cx, cy, c.w * 0.85);
radGrad.addColorStop(0, "rgba(251, 191, 36, 0.20)");
radGrad.addColorStop(0.6, "rgba(56, 189, 248, 0.10)");
radGrad.addColorStop(1, "rgba(0, 0, 0, 0)");
ctx.beginPath();
ctx.arc(cx, cy, c.w * 0.85, 0, Math.PI * 2);
ctx.fillStyle = radGrad;
ctx.fill();
});
if (isClassBreak) {
animId = requestAnimationFrame(renderAuroraWaves);
}
}
function checkScheduleStatus(now) {
const curMinutes = now.getHours() * 60 + now.getMinutes();
let inClass = false;
for (let slot of appState.schedule) {
const [start, end] = slot.split("-");
if (!start || !end) continue;
const [sh, sm] = start.split(":").map(Number);
const [eh, em] = end.split(":").map(Number);
const sMin = sh * 60 + sm;
const eMin = eh * 60 + em;
if (curMinutes >= sMin && curMinutes < eMin) {
inClass = true;
break;
}
}
const indicator = document.getElementById("periodIndicator");
if (inClass) {
isClassBreak = false;
indicator.textContent = "課堂專注模式";
indicator.className = "period-indicator";
canvas.classList.remove("active");
if (animId) {
cancelAnimationFrame(animId);
animId = null;
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
} else {
isClassBreak = true;
indicator.textContent = "✦ 下課極光流動";
indicator.className = "period-indicator break";
canvas.classList.add("active");
if (!animId) {
animId = requestAnimationFrame(renderAuroraWaves);
}
}
}
function updateClock() {
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
document.getElementById("liveClock").textContent = `${h}:${m}:${s}`;
checkScheduleStatus(now);
}
setInterval(updateClock, 1000);
updateClock();
updateDailyQuote();
function enterEditMode() {
isEditing = true;
document.body.classList.add("is-editing");
}
function exitEditMode() {
isEditing = false;
document.body.classList.remove("is-editing");
saveAppData();
}
function openAdminDrawer() {
document.getElementById("themeSelect").value = appState.theme;
document.getElementById("scheduleInput").value = appState.schedule.join("\n");
renderDrawerEditor();
document.getElementById("adminDrawer").style.display = "flex";
}
function closeAdminDrawer() {
document.getElementById("adminDrawer").style.display = "none";
}
function renderDrawerEditor() {
const container = document.getElementById("editorItemList");
container.innerHTML = "";
appState.cards.forEach((item, index) => {
const row = document.createElement("div");
row.className = "item-row";
row.innerHTML = `
<div class="item-inputs">
<input type="text" value="${item.title}" placeholder="名稱" onchange="appState.cards[${index}].title=this.value">
<input type="date" value="${item.date}" onchange="appState.cards[${index}].date=this.value">
</div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<select onchange="appState.cards[${index}].cat=this.value; renderBoard();">
<option value="exam" ${item.cat==='exam'?'selected':''}>學業考試 (書本)</option>
<option value="sports" ${item.cat==='sports'?'selected':''}>運動競賽 (獎盃)</option>
<option value="event" ${item.cat==='event'?'selected':''}>校慶活動 (旗幟)</option>
<option value="life" ${item.cat==='life'?'selected':''}>常態生活 (時鐘)</option>
</select>
<label style="font-size:0.8rem; color:#cbd5e1; display:flex; align-items:center; gap:4px; cursor:pointer;">
<input type="checkbox" ${item.highlight ? 'checked':''} onchange="toggleCardHighlight(${index}, this.checked)"> 重要高亮
</label>
<button class="btn btn-danger" style="padding:4px 8px;" onclick="removeCard(${index})">刪除</button>
</div>
`;
container.appendChild(row);
});
}
function toggleCardHighlight(idx, isChecked) {
appState.cards[idx].highlight = isChecked;
renderBoard();
}
function addNewEvent() {
const todayStr = new Date().toISOString().split('T')[0];
appState.cards.push({
id: String(Date.now()),
title: "新倒數目標",
date: todayStr,
cat: "exam",
highlight: false,
x: 120 + (appState.cards.length * 30),
y: 200,
w: 320,
h: 260,
totalSpan: 100
});
renderDrawerEditor();
renderBoard();
}
function removeCard(idx) {
appState.cards.splice(idx, 1);
renderDrawerEditor();
renderBoard();
}
function saveAllSettings() {
const scheduleLines = document.getElementById("scheduleInput").value
.split("\n")
.map(s => s.trim())
.filter(s => s.length > 0);
appState.schedule = scheduleLines;
saveAppData();
renderBoard();
closeAdminDrawer();
}
renderBoard();
</script>
</body></html>
</html>