Download index.html from Lashtw/Countdown: direct link, hf CLI and curl.
- Browser
- Download file 63.2 kB
-
https://huggingface.co/spaces/Lashtw/Countdown/resolve/main/index.html
- Command line
-
hf download hf://spaces/Lashtw/Countdown/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Lashtw/Countdown/resolve/main/index.html
63.2 kB
| <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 ; | |
| 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> |