paper_server/todo.html

661 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chronicle — 待办</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Cormorant+SC:wght@300;400;500;600&family=EB+Garamond:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0B0E16;
--bg2: #111520;
--bg3: #181D2B;
--gold: #C9A55E;
--gold-lt: #E0C07A;
--gold-dim: rgba(201,165,94,.28);
--gold-glow: rgba(201,165,94,.12);
--cream: #F0E9DC;
--cream-mid: rgba(240,233,220,.55);
--cream-faint: rgba(240,233,220,.10);
--silver: #7A8499;
--red: #B84E3A;
}
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--cream);
font-family: 'EB Garamond', Georgia, serif;
font-size: 18px;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 64px 24px 120px;
position: relative;
overflow-x: hidden;
}
/* Radial ambient glow */
body::after {
content: '';
position: fixed;
top: -20%;
left: 50%;
transform: translateX(-50%);
width: 800px;
height: 500px;
background: radial-gradient(ellipse at center, rgba(201,165,94,.06) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
/* Noise grain overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.035'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
}
.container {
width: 100%;
max-width: 620px;
position: relative;
z-index: 1;
}
/* ── HEADER ─────────────────────────────────────────── */
.header {
text-align: center;
margin-bottom: 48px;
animation: fadeDown .7s ease both;
}
.eyebrow {
font-family: 'Cormorant SC', serif;
font-size: 10.5px;
letter-spacing: 7px;
color: var(--gold);
text-transform: uppercase;
opacity: .85;
margin-bottom: 14px;
}
.wordmark {
font-family: 'Cormorant SC', serif;
font-size: 62px;
font-weight: 300;
letter-spacing: 10px;
color: var(--cream);
line-height: 1;
margin-bottom: 18px;
text-shadow: 0 0 60px rgba(201,165,94,.15);
}
.dateline {
font-style: italic;
font-size: 15.5px;
color: var(--silver);
letter-spacing: .8px;
}
.ornament-row {
display: flex;
align-items: center;
gap: 14px;
margin: 26px 0 0;
}
.ornament-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--gold-dim), transparent);
}
.ornament-gem { color: var(--gold); font-size: 9px; }
/* Progress ring */
.progress-wrap {
margin: 28px auto 0;
width: 72px;
height: 72px;
position: relative;
display: none;
}
.progress-wrap.visible { display: block; }
.progress-ring { transform: rotate(-90deg); display: block; }
.ring-bg { fill: none; stroke: var(--cream-faint); stroke-width: 2; }
.ring-fill {
fill: none;
stroke: var(--gold);
stroke-width: 2;
stroke-linecap: round;
stroke-dasharray: 197.92;
stroke-dashoffset: 197.92;
transition: stroke-dashoffset .7s cubic-bezier(.4,0,.2,1);
}
.ring-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Cormorant SC', serif;
font-size: 14px;
color: var(--gold);
}
/* ── INPUT ───────────────────────────────────────────── */
.input-area {
margin-bottom: 36px;
animation: fadeUp .7s .18s ease both;
}
.input-shell {
display: flex;
align-items: center;
background: var(--bg2);
border: 1px solid var(--gold-dim);
padding: 0 18px;
transition: border-color .3s, box-shadow .3s;
}
.input-shell:focus-within {
border-color: rgba(201,165,94,.7);
box-shadow: 0 0 0 1px var(--gold-dim), 0 12px 40px var(--gold-glow);
}
.input-bullet {
color: var(--gold);
font-size: 9px;
margin-right: 14px;
flex-shrink: 0;
opacity: .8;
}
.todo-input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--cream);
font-family: 'EB Garamond', serif;
font-size: 18px;
padding: 17px 0;
letter-spacing: .2px;
}
.todo-input::placeholder { color: var(--silver); font-style: italic; opacity: .55; }
.add-btn {
background: none;
border: none;
cursor: pointer;
color: var(--gold);
padding: 0 0 0 14px;
display: flex;
align-items: center;
opacity: .65;
transition: opacity .2s, transform .2s;
flex-shrink: 0;
}
.add-btn:hover { opacity: 1; transform: scale(1.18) rotate(90deg); }
/* ── STATS BAR ───────────────────────────────────────── */
.stats-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2px;
margin-bottom: 20px;
animation: fadeUp .7s .28s ease both;
}
.active-count {
font-family: 'Cormorant SC', serif;
font-size: 13px;
letter-spacing: 3px;
color: var(--silver);
}
.active-count em { color: var(--gold); font-style: normal; font-size: 16px; }
.filters { display: flex; gap: 0; }
.ftab {
background: none;
border: none;
cursor: pointer;
font-family: 'Cormorant SC', serif;
font-size: 12px;
letter-spacing: 2px;
color: var(--silver);
padding: 4px 14px;
transition: color .2s;
position: relative;
}
.ftab::after {
content: '';
position: absolute;
bottom: -1px;
left: 14px; right: 14px;
height: 1px;
background: var(--gold);
transform: scaleX(0);
transition: transform .25s ease;
transform-origin: center;
}
.ftab.on { color: var(--gold-lt); }
.ftab.on::after { transform: scaleX(1); }
.ftab:hover:not(.on) { color: var(--cream-mid); }
/* ── LIST ────────────────────────────────────────────── */
.todo-list {
list-style: none;
animation: fadeUp .7s .38s ease both;
}
.todo-item {
display: flex;
align-items: flex-start;
gap: 15px;
padding: 15px 0;
border-bottom: 1px solid var(--cream-faint);
position: relative;
animation: itemIn .32s ease both;
}
.todo-item:first-child { border-top: 1px solid var(--cream-faint); }
@keyframes itemIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes itemOut {
to { opacity: 0; transform: translateX(16px); max-height: 0; padding: 0; margin: 0; overflow: hidden; }
}
.todo-item.bye { animation: itemOut .28s ease forwards; }
/* Checkbox */
.chk {
width: 21px; height: 21px;
border: 1px solid var(--gold-dim);
border-radius: 50%;
cursor: pointer;
flex-shrink: 0;
margin-top: 3px;
display: flex; align-items: center; justify-content: center;
transition: border-color .25s, background .25s, box-shadow .25s;
position: relative;
}
.chk:hover { border-color: var(--gold); box-shadow: 0 0 10px rgba(201,165,94,.25); }
.chk.on {
background: var(--gold);
border-color: var(--gold);
box-shadow: 0 0 12px rgba(201,165,94,.3);
}
.chk.on::after {
content: '';
display: block;
width: 5px; height: 9px;
border-right: 1.5px solid var(--bg);
border-bottom: 1.5px solid var(--bg);
transform: rotate(45deg) translate(-1px,-1px);
}
/* Content */
.todo-body { flex: 1; min-width: 0; }
.todo-text {
font-size: 18px;
line-height: 1.55;
color: var(--cream);
word-break: break-word;
outline: none;
cursor: text;
transition: color .3s;
caret-color: var(--gold);
}
.todo-text:focus { color: var(--cream); }
.todo-item.done .todo-text {
color: var(--silver);
text-decoration: line-through;
text-decoration-color: rgba(201,165,94,.35);
text-decoration-thickness: 1px;
}
.todo-time {
font-size: 13px;
font-style: italic;
color: var(--silver);
opacity: .5;
margin-top: 3px;
}
/* Actions */
.todo-acts {
display: flex;
gap: 6px;
align-items: center;
margin-top: 4px;
opacity: 0;
transition: opacity .2s;
}
.todo-item:hover .todo-acts { opacity: 1; }
.act-btn {
background: none;
border: none;
cursor: pointer;
color: var(--silver);
padding: 3px;
display: flex; align-items: center;
transition: color .2s, transform .2s;
opacity: .7;
}
.act-btn:hover { color: var(--red); transform: scale(1.2); opacity: 1; }
/* ── EMPTY STATE ─────────────────────────────────────── */
.empty {
text-align: center;
padding: 64px 20px;
animation: fadeUp .5s ease both;
}
.empty-icon {
font-family: 'Cormorant SC', serif;
font-size: 52px;
color: rgba(201,165,94,.2);
margin-bottom: 16px;
display: block;
line-height: 1;
}
.empty-msg { font-style: italic; color: var(--silver); font-size: 17px; }
/* ── FOOTER ──────────────────────────────────────────── */
.footer {
margin-top: 32px;
display: flex;
justify-content: flex-end;
animation: fadeUp .7s .48s ease both;
}
.clear-btn {
background: none;
border: none;
cursor: pointer;
font-family: 'Cormorant SC', serif;
font-size: 12px;
letter-spacing: 2.5px;
color: var(--silver);
transition: color .2s;
padding: 4px 0;
}
.clear-btn:hover:not(:disabled) { color: var(--red); }
.clear-btn:disabled { opacity: .25; cursor: default; pointer-events: none; }
/* ── KEYFRAMES ───────────────────────────────────────── */
@keyframes fadeDown {
from { opacity: 0; transform: translateY(-22px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(22px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes addPulse {
0% { box-shadow: 0 0 0 0 rgba(201,165,94,.45); }
70% { box-shadow: 0 0 0 12px rgba(201,165,94,0); }
100% { box-shadow: 0 0 0 0 rgba(201,165,94,0); }
}
.input-shell.pulse { animation: addPulse .55s ease; }
/* ── SCROLLBAR ───────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--gold-dim); border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<!-- HEADER -->
<header class="header">
<div class="eyebrow">&nbsp; Chronicle &nbsp;</div>
<div class="wordmark">&nbsp;</div>
<div class="dateline" id="dateline"></div>
<div class="ornament-row">
<div class="ornament-line"></div>
<span class="ornament-gem"></span>
<div class="ornament-line"></div>
</div>
<!-- Progress ring -->
<div class="progress-wrap" id="progWrap">
<svg class="progress-ring" width="72" height="72" viewBox="0 0 72 72">
<circle class="ring-bg" cx="36" cy="36" r="31.5"/>
<circle class="ring-fill" id="ringFill" cx="36" cy="36" r="31.5"/>
</svg>
<div class="ring-label" id="ringLabel">0%</div>
</div>
</header>
<!-- INPUT -->
<div class="input-area">
<div class="input-shell" id="inputShell">
<span class="input-bullet"></span>
<input class="todo-input" id="mainInput"
placeholder="记录一件待办事项…"
maxlength="200"
autocomplete="off" />
<button class="add-btn" id="addBtn" title="添加 (Enter)">
<svg width="17" height="17" viewBox="0 0 17 17" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<line x1="8.5" y1="2" x2="8.5" y2="15"/>
<line x1="2" y1="8.5" x2="15" y2="8.5"/>
</svg>
</button>
</div>
</div>
<!-- STATS BAR -->
<div class="stats-bar">
<div class="active-count"><em id="cntEl">0</em>&nbsp;项待完成</div>
<div class="filters">
<button class="ftab on" data-f="all">全部</button>
<button class="ftab" data-f="active">进行中</button>
<button class="ftab" data-f="done">已完成</button>
</div>
</div>
<!-- LIST -->
<ul class="todo-list" id="todoList"></ul>
<!-- FOOTER -->
<div class="footer">
<button class="clear-btn" id="clearBtn" disabled>清除已完成</button>
</div>
</div>
<script>
(function () {
/* ── STATE ─────────────────────────────────────── */
let todos = JSON.parse(localStorage.getItem('chronicle_v2') || '[]');
let filter = 'all';
/* ── ELEMENTS ──────────────────────────────────── */
const listEl = document.getElementById('todoList');
const mainInput = document.getElementById('mainInput');
const addBtn = document.getElementById('addBtn');
const cntEl = document.getElementById('cntEl');
const clearBtn = document.getElementById('clearBtn');
const inputShell= document.getElementById('inputShell');
const progWrap = document.getElementById('progWrap');
const ringFill = document.getElementById('ringFill');
const ringLabel = document.getElementById('ringLabel');
const CIRC = 197.92; // 2π × 31.5
/* ── DATE ──────────────────────────────────────── */
const WD = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'];
const now = new Date();
document.getElementById('dateline').textContent =
`${now.getFullYear()} · ${pad(now.getMonth()+1)} · ${pad(now.getDate())} · ${WD[now.getDay()]}`;
function pad(n) { return String(n).padStart(2,'0'); }
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2,5); }
function save() { localStorage.setItem('chronicle_v2', JSON.stringify(todos)); }
function ftime(ts) {
const d = new Date(ts);
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function esc(s) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(s));
return d.innerHTML;
}
/* ── STATS ─────────────────────────────────────── */
function updateStats() {
const active = todos.filter(t => !t.done).length;
const done = todos.filter(t => t.done).length;
const total = todos.length;
cntEl.textContent = active;
clearBtn.disabled = done === 0;
if (total > 0) {
progWrap.classList.add('visible');
const pct = Math.round(done / total * 100);
const offset = CIRC * (1 - done / total);
ringFill.style.strokeDashoffset = offset;
ringLabel.textContent = pct + '%';
} else {
progWrap.classList.remove('visible');
}
}
/* ── RENDER ────────────────────────────────────── */
const EMPTY = {
all: { icon: '◇', msg: '万事俱备,只欠东风' },
active: { icon: '✦', msg: '所有任务均已完成' },
done: { icon: '○', msg: '尚无已完成的事项' },
};
function render() {
const visible = filter === 'all' ? todos
: filter === 'active' ? todos.filter(t => !t.done)
: todos.filter(t => t.done);
listEl.innerHTML = '';
if (!visible.length) {
const e = EMPTY[filter];
listEl.innerHTML = `
<div class="empty">
<span class="empty-icon">${e.icon}</span>
<p class="empty-msg">${e.msg}</p>
</div>`;
} else {
visible.forEach((t, i) => {
const li = document.createElement('li');
li.className = 'todo-item' + (t.done ? ' done' : '');
li.dataset.id = t.id;
li.style.animationDelay = `${i * .045}s`;
li.innerHTML = `
<div class="chk${t.done ? ' on' : ''}" data-action="toggle" title="切换完成"></div>
<div class="todo-body">
<div class="todo-text" contenteditable="true" data-action="edit"
spellcheck="false">${esc(t.text)}</div>
<div class="todo-time">${ftime(t.createdAt)}</div>
</div>
<div class="todo-acts">
<button class="act-btn" data-action="del" title="删除">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<line x1="1.5" y1="1.5" x2="11.5" y2="11.5"/>
<line x1="11.5" y1="1.5" x2="1.5" y2="11.5"/>
</svg>
</button>
</div>`;
listEl.appendChild(li);
});
}
updateStats();
}
/* ── ADD ───────────────────────────────────────── */
function add(raw) {
const text = raw.trim();
if (!text) return;
todos.unshift({ id: uid(), text, done: false, createdAt: Date.now() });
save(); render();
inputShell.classList.remove('pulse');
void inputShell.offsetWidth;
inputShell.classList.add('pulse');
}
addBtn.addEventListener('click', () => { add(mainInput.value); mainInput.value = ''; mainInput.focus(); });
mainInput.addEventListener('keydown', e => {
if (e.key === 'Enter') { add(mainInput.value); mainInput.value = ''; }
});
/* ── LIST EVENTS ───────────────────────────────── */
listEl.addEventListener('click', e => {
const src = e.target.closest('[data-action]');
if (!src) return;
const li = src.closest('.todo-item');
if (!li) return;
const id = li.dataset.id;
if (src.dataset.action === 'toggle') {
const t = todos.find(x => x.id === id);
if (t) { t.done = !t.done; save(); render(); }
} else if (src.dataset.action === 'del') {
li.classList.add('bye');
setTimeout(() => { todos = todos.filter(x => x.id !== id); save(); render(); }, 290);
}
});
listEl.addEventListener('blur', e => {
if (e.target.dataset.action !== 'edit') return;
const li = e.target.closest('.todo-item');
if (!li) return;
const id = li.dataset.id;
const text = e.target.textContent.trim();
if (!text) {
todos = todos.filter(x => x.id !== id);
} else {
const t = todos.find(x => x.id === id);
if (t) t.text = text;
}
save(); render();
}, true);
listEl.addEventListener('keydown', e => {
if (e.target.dataset.action === 'edit' && e.key === 'Enter') {
e.preventDefault(); e.target.blur();
}
});
/* ── FILTERS ───────────────────────────────────── */
document.querySelectorAll('.ftab').forEach(btn => {
btn.addEventListener('click', () => {
filter = btn.dataset.f;
document.querySelectorAll('.ftab').forEach(b => b.classList.remove('on'));
btn.classList.add('on');
render();
});
});
/* ── CLEAR DONE ────────────────────────────────── */
clearBtn.addEventListener('click', () => {
document.querySelectorAll('.todo-item.done').forEach(li => li.classList.add('bye'));
setTimeout(() => { todos = todos.filter(t => !t.done); save(); render(); }, 300);
});
/* ── INIT ──────────────────────────────────────── */
render();
})();
</script>
</body>
</html>