docs: design handoff, manual events and archive in plan, spec and functional doc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
456
docs/design/handoff/hado-inbox.dc.html
Normal file
456
docs/design/handoff/hado-inbox.dc.html
Normal file
@@ -0,0 +1,456 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;700&family=Space+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#070912;--ink-0:#090B16;--ink-1:#11152E;--ink-2:#1B2148;--ink-3:#2B3566;--sakura:#F7CEDC;--nadeshiko:#EA8FAE;--tsutsuji:#E84E8A;--kobai:#C13E6A;--yamabuki:#F2B233;--hanada:#1F7FC4;--ruri:#355CD6;--byakugun:#8FD6DC;--fuji:#9F8FCB;--washi:#F2ECE0;--kasumi:#AEB4D6;--nibi:#6B7099;--hair:rgba(174,180,214,0.16);--hair-2:rgba(174,180,214,0.30)}
|
||||
*{box-sizing:border-box}html,body{margin:0;padding:0;background:#070912}
|
||||
::selection{background:rgba(143,214,220,.25)}
|
||||
a{color:var(--byakugun);text-decoration-color:rgba(143,214,220,.4)}a:hover{color:var(--washi)}
|
||||
input,select,button{font:inherit}
|
||||
button[disabled]{opacity:.45;pointer-events:none}
|
||||
.skdot:hover + .sklbl{opacity:1}
|
||||
@keyframes skTwinkle{0%,100%{opacity:.15}50%{opacity:.75}}
|
||||
@keyframes skNebula{0%,100%{transform:rotate(-14deg) translateX(-2.5%)}50%{transform:rotate(-14deg) translateX(2.5%)}}
|
||||
@keyframes skNebulaCore{0%,100%{transform:rotate(-14deg) translateX(3%)}50%{transform:rotate(-14deg) translateX(-3%)}}
|
||||
@keyframes skOrbXa{0%,100%{transform:translateX(36px)}50%{transform:translateX(-36px)}}
|
||||
@keyframes skOrbYa{0%,100%{transform:translateY(13px)}50%{transform:translateY(-13px)}}
|
||||
@keyframes skOrbXb{0%,100%{transform:translateX(24px)}50%{transform:translateX(-24px)}}
|
||||
@keyframes skOrbYb{0%,100%{transform:translateY(22px)}50%{transform:translateY(-22px)}}
|
||||
@keyframes skOrbXc{0%,100%{transform:translateX(14px)}50%{transform:translateX(-14px)}}
|
||||
@keyframes skOrbYc{0%,100%{transform:translateY(28px)}50%{transform:translateY(-28px)}}
|
||||
@keyframes skOrbXd{0%,100%{transform:translateX(-20px)}50%{transform:translateX(20px)}}
|
||||
@keyframes skOrbYd{0%,100%{transform:translateY(16px)}50%{transform:translateY(-16px)}}
|
||||
@keyframes skPulseDot{0%,100%{box-shadow:0 0 6px rgba(53,92,214,.5)}50%{box-shadow:0 0 14px rgba(53,92,214,.95)}}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="min-height:100vh;background:var(--bg);color:var(--washi);font-family:'Space Grotesk',sans-serif;position:relative;overflow:hidden;display:flex;flex-direction:column">
|
||||
<!-- атмосфера -->
|
||||
<div ref="{{ starsRef }}" style="position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0"></div>
|
||||
<div style="position:absolute;pointer-events:none;z-index:0;top:26%;left:-22%;width:150%;height:52%;filter:blur(34px);opacity:.72;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 42% 58% at 20% 60%, rgba(234,143,174,.20), transparent 70%),radial-gradient(ellipse 58% 46% at 48% 44%, rgba(232,78,138,.14), transparent 72%),radial-gradient(ellipse 38% 54% at 70% 57%, rgba(159,143,203,.17), transparent 70%),radial-gradient(ellipse 28% 40% at 88% 51%, rgba(143,214,220,.11), transparent 70%),radial-gradient(ellipse 52% 34% at 36% 62%, rgba(53,92,214,.11), transparent 72%)"></div>
|
||||
<div style="position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(14px);opacity:.7;animation:skNebulaCore 58s ease-in-out infinite;background:radial-gradient(ellipse 20% 70% at 8% 71%, rgba(247,206,220,.22), transparent 70%),radial-gradient(ellipse 14% 90% at 22% 52%, rgba(242,236,224,.18), transparent 70%),radial-gradient(ellipse 18% 80% at 36% 48%, rgba(234,143,174,.22), transparent 72%),radial-gradient(ellipse 12% 100% at 48% 38%, rgba(247,206,220,.24), transparent 70%),radial-gradient(ellipse 16% 85% at 60% 43%, rgba(242,236,224,.17), transparent 72%),radial-gradient(ellipse 13% 75% at 72% 43%, rgba(234,143,174,.20), transparent 70%),radial-gradient(ellipse 18% 90% at 86% 55%, rgba(247,206,220,.18), transparent 72%),radial-gradient(ellipse 10% 60% at 94% 61%, rgba(143,214,220,.12), transparent 70%)"></div>
|
||||
<div style="position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(10px);opacity:.8;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 20% 38% at 16% 72%, rgba(7,9,18,.6), transparent 75%),radial-gradient(ellipse 28% 34% at 44% 55%, rgba(9,11,22,.55), transparent 75%),radial-gradient(ellipse 18% 38% at 70% 50%, rgba(7,9,18,.55), transparent 75%),radial-gradient(ellipse 14% 30% at 90% 68%, rgba(9,11,22,.5), transparent 75%)"></div>
|
||||
<div ref="{{ bandRef }}" style="position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;transform:rotate(-14deg)"></div>
|
||||
|
||||
<!-- сестринские планеты: все, кроме Hado -->
|
||||
<div style="position:absolute;inset:0;pointer-events:none;z-index:0">
|
||||
<div style="position:absolute;top:17%;left:7%">
|
||||
<div style="animation:skOrbXa 19s ease-in-out infinite">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:9px;animation:skOrbYa 19s ease-in-out -4.75s infinite">
|
||||
<div class="skdot" style="pointer-events:auto;width:15px;height:15px;border-radius:50%;background:radial-gradient(circle at 32% 28%, var(--sakura), var(--nadeshiko) 50%, var(--ink-0) 92%);box-shadow:0 0 10px rgba(234,143,174,.4)"></div>
|
||||
<span class="sklbl" style="font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s">YUME</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:absolute;top:26%;right:8%">
|
||||
<div style="animation:skOrbXb 27s ease-in-out infinite">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:9px;animation:skOrbYb 27s ease-in-out -20.25s infinite">
|
||||
<div class="skdot" style="pointer-events:auto;width:12px;height:12px;border-radius:50%;background:radial-gradient(circle at 32% 28%, var(--tsutsuji), var(--kobai) 52%, var(--ink-0) 92%);box-shadow:0 0 9px rgba(232,78,138,.4)"></div>
|
||||
<span class="sklbl" style="font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s">MYAKU</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:absolute;top:60%;left:4.5%">
|
||||
<div style="animation:skOrbXc 14s ease-in-out infinite">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;animation:skOrbYc 14s ease-in-out -3.5s infinite">
|
||||
<div class="skdot" style="pointer-events:auto;width:9px;height:9px;border-radius:50%;background:radial-gradient(circle at 34% 30%, var(--fuji), #4A4177 55%, var(--ink-0) 92%);box-shadow:0 0 8px rgba(159,143,203,.4)"></div>
|
||||
<span class="sklbl" style="font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s">KIOKU</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:absolute;top:64%;right:6%">
|
||||
<div style="animation:skOrbXd 22s ease-in-out infinite">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;animation:skOrbYd 22s ease-in-out -5.5s infinite">
|
||||
<div class="skdot" style="pointer-events:auto;width:10px;height:10px;border-radius:50%;background:radial-gradient(circle at 32% 28%, var(--byakugun), var(--hanada) 50%, var(--ink-0) 93%);box-shadow:0 0 9px rgba(143,214,220,.4)"></div>
|
||||
<span class="sklbl" style="font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s">FUMI</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header style="position:relative;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;padding:30px 44px 0 44px">
|
||||
<div style="display:flex;align-items:baseline;gap:14px">
|
||||
<span style="font-family:'Shippori Mincho',serif;font-size:54px;line-height:1;color:var(--washi)">波動</span>
|
||||
<span style="font-family:'Space Mono',monospace;font-size:13px;letter-spacing:.5em;color:var(--nibi)">HADO</span>
|
||||
<sc-if value="{{ hasCounter }}" hint-placeholder-val="{{ true }}">
|
||||
<span style="display:inline-flex;align-items:center;gap:8px;padding:5px 12px 5px 10px;border:1px solid rgba(53,92,214,.55);border-radius:999px;background:rgba(53,92,214,.14)">
|
||||
<span style="width:11px;height:11px;border-radius:50%;background:radial-gradient(circle at 32% 28%, var(--ruri), #24337F 55%, var(--ink-0) 92%);animation:skPulseDot 2.6s ease-in-out infinite"></span>
|
||||
<span style="font-family:'Space Mono',monospace;font-size:12px;color:var(--byakugun)">{{ counter }}</span>
|
||||
</span>
|
||||
</sc-if>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:16px;margin-top:14px">
|
||||
<button onClick="{{ toggleAdd }}" style="background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun);border-radius:8px;padding:8px 18px;font-size:13px;cursor:pointer;pointer-events:auto" style-hover="background:rgba(53,92,214,.4)">+ Добавить</button>
|
||||
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)">05</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main style="position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:72px 32px 100px 32px;flex:1">
|
||||
<sc-if value="{{ addOpen }}">
|
||||
<section style="margin-bottom:52px;border:1px dashed var(--hair-2);border-radius:14px;background:rgba(17,21,46,.7);padding:22px">
|
||||
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px">НОВОЕ СОБЫТИЕ</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end">
|
||||
<label style="display:flex;flex-direction:column;gap:6px;flex:2;min-width:220px">
|
||||
<span style="font-size:12px;color:var(--nibi)">Заголовок</span>
|
||||
<input value="{{ nTitle }}" onChange="{{ onNTitle }}" placeholder="Продлить страховку" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px">
|
||||
</label>
|
||||
<label style="display:flex;flex-direction:column;gap:6px;flex:1;min-width:160px">
|
||||
<span style="font-size:12px;color:var(--nibi)">Подзаголовок (опц.)</span>
|
||||
<input value="{{ nSub }}" onChange="{{ onNSub }}" placeholder="раз в год" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px">
|
||||
</label>
|
||||
<label style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="font-size:12px;color:var(--nibi)">Срок</span>
|
||||
<input type="date" value="{{ nDate }}" onChange="{{ onNDate }}" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:8px 12px;font-size:14px">
|
||||
</label>
|
||||
<label style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="font-size:12px;color:var(--nibi)">Время (опц.)</span>
|
||||
<input type="time" value="{{ nTime }}" onChange="{{ onNTime }}" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:8px 12px;font-size:14px">
|
||||
</label>
|
||||
<button onClick="{{ addEvent }}" style="background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun);border-radius:8px;padding:9px 18px;font-size:13px;cursor:pointer" style-hover="background:rgba(53,92,214,.4)">Создать</button>
|
||||
<button onClick="{{ toggleAdd }}" style="background:transparent;border:1px solid var(--hair-2);color:var(--kasumi);border-radius:8px;padding:9px 14px;font-size:13px;cursor:pointer" style-hover="border-color:var(--byakugun);color:var(--byakugun)">Отмена</button>
|
||||
</div>
|
||||
<sc-if value="{{ nErr }}">
|
||||
<div style="color:var(--sakura);font-size:12px;margin-top:12px">{{ nErr }}</div>
|
||||
</sc-if>
|
||||
</section>
|
||||
</sc-if>
|
||||
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="4">
|
||||
<section style="margin-bottom:52px">
|
||||
<div style="display:flex;align-items:baseline;gap:12px;margin-bottom:18px">
|
||||
<span style="font-family:'Shippori Mincho',serif;font-size:20px;color:var(--washi)">{{ g.jp }}</span>
|
||||
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.35em;color:var(--kasumi)">{{ g.ru }}</span>
|
||||
<span style="flex:1;border-bottom:1px solid var(--hair)"></span>
|
||||
</div>
|
||||
<sc-if value="{{ g.empty }}">
|
||||
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi);padding:12px 2px">ПУСТО</div>
|
||||
</sc-if>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<sc-for list="{{ g.events }}" as="ev" hint-placeholder-count="1">
|
||||
<article style="background:rgba(27,33,72,.72);border:1px solid var(--hair);border-radius:12px;padding:16px 20px;display:flex;align-items:center;gap:18px;flex-wrap:wrap">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;min-width:64px;padding:8px 10px;border:1px solid var(--hair-2);border-radius:10px;background:var(--ink-1)">
|
||||
<span style="font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.2em;color:var(--nibi)">{{ ev.dueMonth }}</span>
|
||||
<span style="font-family:'Shippori Mincho',serif;font-size:24px;line-height:1;color:var(--washi)">{{ ev.dueDay }}</span>
|
||||
<sc-if value="{{ ev.dueTime }}">
|
||||
<span style="font-family:'Space Mono',monospace;font-size:10px;color:var(--byakugun)">{{ ev.dueTime }}</span>
|
||||
</sc-if>
|
||||
</div>
|
||||
<div style="flex:1;min-width:220px;display:flex;flex-direction:column;gap:5px">
|
||||
<sc-if value="{{ ev.link }}" hint-placeholder-val="{{ true }}">
|
||||
<a href="{{ ev.link }}" style="align-self:flex-start;font-size:16px;font-weight:500;color:var(--washi);text-decoration:underline;text-decoration-style:dotted;text-decoration-color:var(--hair-2);text-underline-offset:4px" style-hover="color:var(--byakugun)">{{ ev.title }}</a>
|
||||
</sc-if>
|
||||
<sc-if value="{{ ev.noLink }}">
|
||||
<span style="font-size:16px;font-weight:500;color:var(--washi)">{{ ev.title }}</span>
|
||||
</sc-if>
|
||||
<sc-if value="{{ ev.subtitle }}">
|
||||
<span style="font-size:13px;color:var(--kasumi)">{{ ev.subtitle }}</span>
|
||||
</sc-if>
|
||||
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi);margin-top:3px">{{ ev.source }} · срок {{ ev.due }}<sc-if value="{{ ev.rel }}"> · <span style="color:var(--kasumi)">{{ ev.rel }}</span></sc-if></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<sc-if value="{{ ev.canRemind }}" hint-placeholder-val="{{ true }}">
|
||||
<button disabled="{{ ev.busy }}" onClick="{{ ev.onRemind }}" style="background:transparent;border:1px solid var(--hair-2);color:var(--kasumi);border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;transition:border-color .2s,color .2s" style-hover="border-color:var(--byakugun);color:var(--byakugun)">Помню</button>
|
||||
</sc-if>
|
||||
<button disabled="{{ ev.busy }}" onClick="{{ ev.onDone }}" style="background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun);border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;transition:background .2s" style-hover="background:rgba(53,92,214,.4)">{{ ev.doneLabel }}</button>
|
||||
</div>
|
||||
</article>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
<!-- архив -->
|
||||
<section style="margin-top:64px;border:1px solid var(--hair);border-radius:14px;background:rgba(17,21,46,.7);overflow:hidden">
|
||||
<button onClick="{{ toggleArchive }}" style="width:100%;display:flex;align-items:center;gap:14px;background:none;border:none;padding:18px 22px;cursor:pointer;text-align:left;color:var(--washi)">
|
||||
<span style="font-family:'Shippori Mincho',serif;font-size:16px">書庫</span>
|
||||
<span style="font-size:14px">Архив</span>
|
||||
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;color:var(--nibi)">{{ archiveCount }} закрыто</span>
|
||||
<span style="margin-left:auto;font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi)">{{ archiveChevron }}</span>
|
||||
</button>
|
||||
<sc-if value="{{ archiveOpen }}">
|
||||
<div style="border-top:1px solid var(--hair);padding:18px 22px;display:flex;flex-direction:column;gap:10px">
|
||||
<sc-if value="{{ archiveEmpty }}">
|
||||
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)">ПУСТО</div>
|
||||
</sc-if>
|
||||
<sc-for list="{{ archive }}" as="ar" hint-placeholder-count="2">
|
||||
<div style="display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1);opacity:.75">
|
||||
<span style="font-size:14px;color:var(--kasumi);flex:1;min-width:180px">{{ ar.title }}</span>
|
||||
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi)">{{ ar.source }} · {{ ar.due }}</span>
|
||||
<span style="font-family:'Space Mono',monospace;font-size:10px;letter-spacing:.15em;color:var(--fuji);border:1px solid var(--hair);border-radius:999px;padding:3px 10px">{{ ar.closedAs }}</span>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
</section>
|
||||
|
||||
<!-- настройки -->
|
||||
<section style="margin-top:64px;border:1px solid var(--hair);border-radius:14px;background:rgba(17,21,46,.7);overflow:hidden">
|
||||
<button onClick="{{ toggleSettings }}" style="width:100%;display:flex;align-items:center;gap:14px;background:none;border:none;padding:18px 22px;cursor:pointer;text-align:left;color:var(--washi)">
|
||||
<span style="font-family:'Shippori Mincho',serif;font-size:16px">設定</span>
|
||||
<span style="font-size:14px">Настройки</span>
|
||||
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;color:var(--nibi)">m.orlov</span>
|
||||
<span style="margin-left:auto;font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi)">{{ settingsChevron }}</span>
|
||||
</button>
|
||||
<sc-if value="{{ settingsOpen }}">
|
||||
<div style="border-top:1px solid var(--hair);padding:26px 22px 30px 22px;display:flex;flex-direction:column;gap:34px">
|
||||
|
||||
<div>
|
||||
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px">ПОЯС И ТИХИЕ ЧАСЫ</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end">
|
||||
<label style="display:flex;flex-direction:column;gap:6px;min-width:240px">
|
||||
<span style="font-size:12px;color:var(--nibi)">Часовой пояс</span>
|
||||
<select value="{{ tz }}" onChange="{{ onTz }}" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px">
|
||||
<sc-for list="{{ tzList }}" as="z" hint-placeholder-count="3">
|
||||
<option value="{{ z }}">{{ z }}</option>
|
||||
</sc-for>
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="font-size:12px;color:var(--nibi)">Тихие часы, с</span>
|
||||
<input type="time" value="{{ qFrom }}" onChange="{{ onQFrom }}" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:8px 12px;font-size:14px">
|
||||
</label>
|
||||
<label style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="font-size:12px;color:var(--nibi)">до</span>
|
||||
<input type="time" value="{{ qTo }}" onChange="{{ onQTo }}" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:8px 12px;font-size:14px">
|
||||
</label>
|
||||
<button onClick="{{ saveQuiet }}" style="background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun);border-radius:8px;padding:9px 18px;font-size:13px;cursor:pointer" style-hover="background:rgba(53,92,214,.4)">Сохранить</button>
|
||||
</div>
|
||||
<sc-if value="{{ qErr }}">
|
||||
<div style="color:var(--sakura);font-size:12px;margin-top:12px">{{ qErr }}</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ qSaved }}">
|
||||
<div style="color:var(--byakugun);font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;margin-top:12px">Сохранено — напоминания сдвинуты под пояс</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px">КАНАЛЫ</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<sc-for list="{{ channels }}" as="ch" hint-placeholder-count="3">
|
||||
<div style="display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1)">
|
||||
<span style="font-size:14px;color:var(--washi);min-width:76px">{{ ch.name }}</span>
|
||||
<span style="font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi);flex:1;overflow-wrap:anywhere">{{ ch.note }}</span>
|
||||
<sc-if value="{{ ch.removable }}">
|
||||
<button onClick="{{ ch.onDelete }}" style="background:transparent;border:1px solid var(--hair-2);color:var(--kasumi);border-radius:8px;padding:6px 12px;font-size:12px;cursor:pointer" style-hover="border-color:var(--nadeshiko);color:var(--sakura)">Удалить</button>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px">ДОБАВИТЬ WEBHOOK</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end">
|
||||
<label style="display:flex;flex-direction:column;gap:6px;flex:1;min-width:220px">
|
||||
<span style="font-size:12px;color:var(--nibi)">Адрес доставки</span>
|
||||
<input type="url" value="{{ whUrl }}" onChange="{{ onWhUrl }}" placeholder="https://home.local/hado/notify" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px">
|
||||
</label>
|
||||
<label style="display:flex;flex-direction:column;gap:6px;flex:1;min-width:220px">
|
||||
<span style="font-size:12px;color:var(--nibi)">Адрес присутствия</span>
|
||||
<input type="url" value="{{ whPresence }}" onChange="{{ onWhPresence }}" placeholder="https://home.local/presence" style="background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px">
|
||||
</label>
|
||||
<button onClick="{{ addWebhook }}" style="background:transparent;border:1px solid var(--hair-2);color:var(--kasumi);border-radius:8px;padding:9px 18px;font-size:13px;cursor:pointer" style-hover="border-color:var(--byakugun);color:var(--byakugun)">Добавить webhook</button>
|
||||
</div>
|
||||
<sc-if value="{{ whErr }}">
|
||||
<div style="color:var(--sakura);font-size:12px;margin-top:12px">{{ whErr }}</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px">TELEGRAM</div>
|
||||
<sc-if value="{{ noTgCode }}" hint-placeholder-val="{{ true }}">
|
||||
<button onClick="{{ connectTg }}" style="background:transparent;border:1px solid var(--hair-2);color:var(--kasumi);border-radius:8px;padding:9px 18px;font-size:13px;cursor:pointer" style-hover="border-color:var(--byakugun);color:var(--byakugun)">Подключить Telegram</button>
|
||||
</sc-if>
|
||||
<sc-if value="{{ tgCode }}">
|
||||
<div style="border:1px dashed var(--hair-2);border-radius:10px;padding:14px 18px;display:flex;flex-direction:column;gap:8px">
|
||||
<span style="font-size:14px;color:var(--washi)">Отправь боту <span style="font-family:'Space Mono',monospace;color:var(--byakugun)">/start {{ tgCode }}</span> или <a href="#tg">открой</a></span>
|
||||
<span style="font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;color:var(--nibi)">Код живёт 15 минут. Канал появится в списке при следующем открытии страницы.</span>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</sc-if>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer style="position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:22px 32px 36px 32px;border-top:1px solid var(--hair);display:flex;justify-content:center;gap:8px;font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)">
|
||||
<span>波動 HADO</span>
|
||||
<span>· 世界 SEKAI</span>
|
||||
</footer>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"showEmpty":{"editor":"boolean","default":true,"tsType":"boolean","section":"Инбокс"},"telegramLinked":{"editor":"boolean","default":true,"tsType":"boolean","section":"Каналы"}}">
|
||||
class Component extends DCLogic {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.starsRef = React.createRef();
|
||||
this.bandRef = React.createRef();
|
||||
const mk = (id, group, title, subtitle, source, due, link, doneLabel) => ({ id, group, title, subtitle, source, due, link, doneLabel: doneLabel || 'Сделано' });
|
||||
this.state = {
|
||||
events: [
|
||||
mk('e1', 'today', 'Сегодня ДР — Вася', 'исполняется 34', 'people', '2026-09-04', '#people/vasya', 'Поздравил'),
|
||||
mk('e2', 'today', 'Вылет GOL 1483 — Монтевидео → Сан-Пауло', null, 'docs', '2026-09-04 19:40', '#docs/gol1483', null),
|
||||
mk('e3', 'overdue', 'Аренда за сентябрь не оплачена', 'срок прошёл 3 дня назад', 'payments', '2026-09-01', null, 'Оплатил'),
|
||||
mk('e4', 'soon', 'Седула истекает', 'осталось 26 дней', 'docs', '2026-09-30', '#docs/cedula', 'Продлил'),
|
||||
mk('e5', 'soon', 'Вылет SU 401 — Стамбул', null, 'docs', '2026-09-10 08:15', '#docs/su401', null),
|
||||
mk('e6', 'planned', 'ДР — Мама', null, 'people', '2026-11-02', '#people/mama', 'Поздравил'),
|
||||
mk('e7', 'planned', 'Продление домена hado.uy', null, 'docs', '2026-12-15', null, 'Продлил')
|
||||
],
|
||||
busy: {}, settingsOpen: false,
|
||||
tz: 'America/Montevideo', qFrom: '22:00', qTo: '09:00', qErr: '', qSaved: false,
|
||||
channels: [
|
||||
{ id: 'web', name: 'Веб', note: 'этот инбокс', removable: false },
|
||||
{ id: 'tg', name: 'Telegram', note: 'чат 88 214 302', removable: true },
|
||||
{ id: 'wh1', name: 'Webhook', note: 'https://home.orlov.uy/hado/notify', removable: true }
|
||||
],
|
||||
whUrl: '', whPresence: '', whErr: '', tgCode: null,
|
||||
archive: [
|
||||
{ id: 'a1', title: 'ДР — Лена', source: 'people', due: '2026-08-28', closedAs: 'Поздравил' },
|
||||
{ id: 'a2', title: 'Аренда за август', source: 'payments', due: '2026-08-01', closedAs: 'Оплатил' },
|
||||
{ id: 'a3', title: 'Виза — подача документов', source: 'docs', due: '2026-07-14', closedAs: 'Сделано' }
|
||||
],
|
||||
archiveOpen: false,
|
||||
addOpen: false, nTitle: '', nSub: '', nDate: '', nTime: '', nErr: ''
|
||||
};
|
||||
try { this.tzList = Intl.supportedValuesOf('timeZone'); }
|
||||
catch (e) { this.tzList = ['America/Montevideo', 'Asia/Shanghai', 'Europe/Belgrade', 'UTC']; }
|
||||
}
|
||||
counter() { return this.state.events.filter(e => e.group === 'today' || e.group === 'overdue').length; }
|
||||
syncTitle() { const c = this.counter(); document.title = c ? '(' + c + ') Hado' : 'Hado'; }
|
||||
componentDidMount() { this.syncTitle(); this.spawnStars(); }
|
||||
componentDidUpdate() { this.syncTitle(); }
|
||||
spawnStars() {
|
||||
const star = (color, size, bright, x, y, dur, delay) => {
|
||||
const s = document.createElement('div');
|
||||
s.style.cssText = 'position:absolute;border-radius:50%;left:' + x + '%;top:' + y + '%;width:' + size + 'px;height:' + size + 'px;background:' + color + ';' + (bright ? 'box-shadow:0 0 6px ' + color + ';' : '') + 'animation:skTwinkle ' + dur + 's ease-in-out ' + delay + 's infinite';
|
||||
return s;
|
||||
};
|
||||
const sky = this.starsRef.current, band = this.bandRef.current;
|
||||
if (!sky || !band || sky.childElementCount > 0) return;
|
||||
for (let i = 0; i < 90; i++) {
|
||||
sky.appendChild(star(Math.random() < 0.2 ? '#8FD6DC' : '#F2ECE0', Math.random() < 0.85 ? 1.5 : 2.5, false,
|
||||
(Math.random() * 100).toFixed(2), (Math.random() * 100).toFixed(2), (3 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
|
||||
}
|
||||
for (let j = 0; j < 170; j++) {
|
||||
const bright = Math.random() < 0.08, r = Math.random();
|
||||
const color = r < 0.68 ? '#F2ECE0' : (r < 0.88 ? '#F7CEDC' : '#8FD6DC');
|
||||
const g = (Math.random() + Math.random() + Math.random()) / 3;
|
||||
const x = Math.random() * 100;
|
||||
const arc = 28 * Math.pow((x - 50) / 50, 2) + Math.max(0, 35 - x) * 0.35;
|
||||
band.appendChild(star(color, bright ? 2.2 : (0.8 + Math.random() * 1.1).toFixed(1), bright,
|
||||
x.toFixed(2), (6 + g * 55 + arc).toFixed(2), (2.5 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1)));
|
||||
}
|
||||
}
|
||||
act(id, remove) {
|
||||
this.setState(s => ({ busy: { ...s.busy, [id]: true } }));
|
||||
setTimeout(() => {
|
||||
this.setState(s => {
|
||||
const ev = s.events.find(e => e.id === id);
|
||||
return {
|
||||
busy: { ...s.busy, [id]: false },
|
||||
events: remove ? s.events.filter(e => e.id !== id) : s.events,
|
||||
archive: remove && ev ? [{ id: 'a' + id, title: ev.title, source: ev.source, due: ev.due, closedAs: ev.doneLabel }, ...s.archive] : s.archive
|
||||
};
|
||||
});
|
||||
}, 700);
|
||||
}
|
||||
dueParts(due) {
|
||||
const m = due.match(/^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}:\d{2}))?$/);
|
||||
if (!m) return { dueMonth: '', dueDay: due, dueTime: null, rel: null };
|
||||
const months = ['ЯНВ', 'ФЕВ', 'МАР', 'АПР', 'МАЙ', 'ИЮН', 'ИЮЛ', 'АВГ', 'СЕН', 'ОКТ', 'НОЯ', 'ДЕК'];
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const d = new Date(+m[1], +m[2] - 1, +m[3]);
|
||||
const diff = Math.round((d - today) / 86400000);
|
||||
const px = n => n % 10 === 1 && n % 100 !== 11 ? 'день' : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 'дня' : 'дней');
|
||||
let rel = null;
|
||||
if (diff === 0) rel = 'сегодня';
|
||||
else if (diff === 1) rel = 'завтра';
|
||||
else if (diff > 1) rel = 'через ' + diff + ' ' + px(diff);
|
||||
else if (diff === -1) rel = 'вчера';
|
||||
else rel = Math.abs(diff) + ' ' + px(Math.abs(diff)) + ' назад';
|
||||
return { dueMonth: months[+m[2] - 1], dueDay: m[3], dueTime: m[4] || null, rel };
|
||||
}
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const showEmpty = this.props.showEmpty ?? true;
|
||||
const tgLinked = this.props.telegramLinked ?? true;
|
||||
const deco = list => list.slice().sort((a, b) => (a.due < b.due ? -1 : 1)).map(e => ({
|
||||
...e, ...this.dueParts(e.due), noLink: !e.link, busy: !!st.busy[e.id], canRemind: e.group !== 'planned',
|
||||
onRemind: () => this.act(e.id, false), onDone: () => this.act(e.id, true)
|
||||
}));
|
||||
const defs = [['today', '今日', 'СЕГОДНЯ'], ['overdue', '遅延', 'ПРОСРОЧЕНО'], ['soon', '近日', 'ГРЯДЁТ'], ['planned', '予定', 'ЗАПЛАНИРОВАНО']];
|
||||
const groups = defs.map(([key, jp, ru]) => {
|
||||
const events = deco(st.events.filter(e => e.group === key));
|
||||
return { key, jp, ru, events, empty: events.length === 0 };
|
||||
}).filter(g => !g.empty || showEmpty);
|
||||
const c = this.counter();
|
||||
return {
|
||||
groups, counter: c, hasCounter: c > 0,
|
||||
starsRef: this.starsRef, bandRef: this.bandRef,
|
||||
settingsOpen: st.settingsOpen, settingsChevron: st.settingsOpen ? '▲' : '▼',
|
||||
toggleSettings: () => this.setState(s => ({ settingsOpen: !s.settingsOpen })),
|
||||
tz: st.tz, tzList: this.tzList, onTz: e => this.setState({ tz: e.target.value }),
|
||||
qFrom: st.qFrom, qTo: st.qTo, qErr: st.qErr, qSaved: st.qSaved,
|
||||
onQFrom: e => this.setState({ qFrom: e.target.value, qSaved: false }),
|
||||
onQTo: e => this.setState({ qTo: e.target.value, qSaved: false }),
|
||||
saveQuiet: () => {
|
||||
if (this.state.qFrom <= this.state.qTo) {
|
||||
this.setState({ qErr: 'Интервал обязан переходить через полночь: «с 22:00 до 09:00» — верно, «с 08:00 до 22:00» — ошибка.', qSaved: false });
|
||||
} else {
|
||||
this.setState({ qErr: '', qSaved: true });
|
||||
clearTimeout(this._qt);
|
||||
this._qt = setTimeout(() => this.setState({ qSaved: false }), 3000);
|
||||
}
|
||||
},
|
||||
channels: st.channels.filter(ch => ch.id !== 'tg' || tgLinked).map(ch => ({
|
||||
...ch, onDelete: () => this.setState(s => ({ channels: s.channels.filter(c2 => c2.id !== ch.id) }))
|
||||
})),
|
||||
whUrl: st.whUrl, whPresence: st.whPresence, whErr: st.whErr,
|
||||
onWhUrl: e => this.setState({ whUrl: e.target.value }),
|
||||
onWhPresence: e => this.setState({ whPresence: e.target.value }),
|
||||
addWebhook: () => {
|
||||
const ok = u => { try { const x = new URL(u); return x.protocol === 'http:' || x.protocol === 'https:'; } catch (e) { return false; } };
|
||||
const { whUrl, whPresence } = this.state;
|
||||
if (!ok(whUrl) || !ok(whPresence)) { this.setState({ whErr: 'Оба поля обязательны и должны быть URL (http/https).' }); return; }
|
||||
this.setState(s => ({ channels: [...s.channels, { id: 'wh' + Date.now(), name: 'Webhook', note: whUrl, removable: true }], whUrl: '', whPresence: '', whErr: '' }));
|
||||
},
|
||||
archive: st.archive, archiveOpen: st.archiveOpen, archiveEmpty: st.archive.length === 0,
|
||||
archiveCount: st.archive.length, archiveChevron: st.archiveOpen ? '▲' : '▼',
|
||||
toggleArchive: () => this.setState(s => ({ archiveOpen: !s.archiveOpen })),
|
||||
addOpen: st.addOpen, nTitle: st.nTitle, nSub: st.nSub, nDate: st.nDate, nTime: st.nTime, nErr: st.nErr,
|
||||
toggleAdd: () => this.setState(s => ({ addOpen: !s.addOpen, nErr: '' })),
|
||||
onNTitle: e => this.setState({ nTitle: e.target.value }),
|
||||
onNSub: e => this.setState({ nSub: e.target.value }),
|
||||
onNDate: e => this.setState({ nDate: e.target.value }),
|
||||
onNTime: e => this.setState({ nTime: e.target.value }),
|
||||
addEvent: () => {
|
||||
const { nTitle, nSub, nDate, nTime } = this.state;
|
||||
if (!nTitle.trim() || !nDate) { this.setState({ nErr: 'Заголовок и срок обязательны.' }); return; }
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const [y, mo, da] = nDate.split('-').map(Number);
|
||||
const diff = Math.round((new Date(y, mo - 1, da) - today) / 86400000);
|
||||
const group = diff < 0 ? 'overdue' : (diff === 0 ? 'today' : (diff <= 30 ? 'soon' : 'planned'));
|
||||
this.setState(s => ({
|
||||
events: [...s.events, { id: 'u' + Date.now(), group, title: nTitle.trim(), subtitle: nSub.trim() || null, source: 'вручную', due: nDate + (nTime ? ' ' + nTime : ''), link: null, doneLabel: 'Сделано' }],
|
||||
addOpen: false, nTitle: '', nSub: '', nDate: '', nTime: '', nErr: ''
|
||||
}));
|
||||
},
|
||||
tgCode: st.tgCode, noTgCode: !st.tgCode,
|
||||
connectTg: () => {
|
||||
const abc = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
this.setState({ tgCode: Array.from({ length: 6 }, () => abc[Math.floor(Math.random() * abc.length)]).join('') });
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user