Перенос дизайна в server-rendered веб-UI (web-ui)
Новая capability web-ui: презентационный перенос готового дизайна
(семантический HTML + единый jellybit.css, тёмная тема по настройке ОС)
в html/template без шага сборки.
- Встроенная (go:embed) отдача статики под /static с Cache-Control и
cache-busting (?v=<hash> по содержимому css/js).
- Шрифты IBM Plex self-hosted (@font-face, cyrillic+latin), без CDN.
- Наколеночный менеджер зависимостей: вендор (htmx + шрифты) не хранится
в репо (gitignore), идемпотентно добывается `task assets` по
web/assets.manifest с проверкой sha256; task build/run зависят от assets.
- Общие партиалы: шапка, бейдж статуса (карта всех 14 состояний),
виджет «файл источника → раскладка» (общий для review и download).
- Страницы: список с фильтром/поиском, ревью, новая страница просмотра
загрузки (/download/{id}). deleted скрыт по умолчанию.
- Превью раскладки берётся из единой логики internal/layout
(buildFileRows), без дублирования правил имён в шаблонах.
- Убраны meta-refresh и инлайн-стили; copyHash на vanilla с fallback
на execCommand и честной индикацией (целевой деплой — HTTP LAN).
Вне scope (отдельный change): живые обновления прогресса и раздел
раздачи, клиентский режим ручной раскладки файл→серия (с Alpine.js).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Манифест фронтенд-ассетов jellybit — источник истины зависимостей фронта.
|
||||
# Формат строки: <путь под web/static/> <url> <sha256>
|
||||
# Обновление: правим версию/URL → 'task assets' (качает и сверяет sha256).
|
||||
# Вендор в репо не коммитим (gitignore); 'task assets' идемпотентно добывает его
|
||||
# в web/static/vendor/ перед сборкой (нет файла или хеш не сошёлся → качаем).
|
||||
# Версии: htmx 2.0.4, IBM Plex Sans 5.2.8, IBM Plex Mono 5.2.7.
|
||||
vendor/htmx.min.js https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447
|
||||
vendor/fonts/ibm-plex-sans-cyrillic-400.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-cyrillic-400-normal.woff2 69afad88a0740b6e5647f1104c9176a261f1ad9d60202f605456a983799752e9
|
||||
vendor/fonts/ibm-plex-sans-latin-400.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-latin-400-normal.woff2 3b646991d30055a93a4ecc499713d4347953a74a947ecab435ab72070cbdab0e
|
||||
vendor/fonts/ibm-plex-sans-cyrillic-500.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-cyrillic-500-normal.woff2 200be3a2a6b1789fb8824a4a74c7cccb7137e83fde6200ea78d8829f99d43546
|
||||
vendor/fonts/ibm-plex-sans-latin-500.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-latin-500-normal.woff2 0717336fb31fcdcde4b8deb3675bb4a0f7f6d484864afcd6751ac29975962203
|
||||
vendor/fonts/ibm-plex-sans-cyrillic-600.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-cyrillic-600-normal.woff2 730becc6f005c18c31440f14d34677f2704a85e6afffd4bee6a64d5c95f45a55
|
||||
vendor/fonts/ibm-plex-sans-latin-600.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-latin-600-normal.woff2 8960851d691c054ed38e259bdcf1a6190d157b4203ed5bb32c632a863fb8ec2f
|
||||
vendor/fonts/ibm-plex-sans-cyrillic-700.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-cyrillic-700-normal.woff2 40540598b4c9eb2cd2562f1017e71f2697adb43e76f49079030dfd7f00b4b786
|
||||
vendor/fonts/ibm-plex-sans-latin-700.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-latin-700-normal.woff2 42e7b0c143c19df9d99fd896e76b48f846edf0902d200bc29796b34d12c33aa7
|
||||
vendor/fonts/ibm-plex-mono-cyrillic-400.woff2 https://unpkg.com/@fontsource/ibm-plex-mono@5.2.7/files/ibm-plex-mono-cyrillic-400-normal.woff2 7635422bd10ddbbcb0e0caa1154b928298f5c8319b8c00b30978d4395f4783ce
|
||||
vendor/fonts/ibm-plex-mono-latin-400.woff2 https://unpkg.com/@fontsource/ibm-plex-mono@5.2.7/files/ibm-plex-mono-latin-400-normal.woff2 08949f728dc52d528e69b1667d15c89a5686a4ee9a296ff90983985f99c380f7
|
||||
vendor/fonts/ibm-plex-mono-cyrillic-500.woff2 https://unpkg.com/@fontsource/ibm-plex-mono@5.2.7/files/ibm-plex-mono-cyrillic-500-normal.woff2 1d89462e86cb8e87e7e9d6f6635605bd7c3081baca6caaecee84916bf7b60edc
|
||||
vendor/fonts/ibm-plex-mono-latin-500.woff2 https://unpkg.com/@fontsource/ibm-plex-mono@5.2.7/files/ibm-plex-mono-latin-500-normal.woff2 01d285447409c8a588692162439a038b8cbd7871309ee20267b0d2d91c6e8e22
|
||||
@@ -0,0 +1,472 @@
|
||||
/* Шрифты IBM Plex — self-hosted (без внешних CDN). Файлы: web/static/fonts/,
|
||||
добываются 'task assets' по web/assets.manifest. Субсеты cyrillic+latin. */
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-cyrillic-400.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-latin-400.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-cyrillic-500.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-latin-500.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-cyrillic-600.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-latin-600.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:700;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-cyrillic-700.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:700;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-latin-700.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(../vendor/fonts/ibm-plex-mono-cyrillic-400.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(../vendor/fonts/ibm-plex-mono-latin-400.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(../vendor/fonts/ibm-plex-mono-cyrillic-500.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(../vendor/fonts/ibm-plex-mono-latin-500.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
|
||||
/* ============================================================
|
||||
jellybit — единый CSS. Переносится в Go html/template как один файл.
|
||||
Тема: спокойный сине-голубой акцент, средняя плотность,
|
||||
IBM Plex Sans + IBM Plex Mono, light/dark по настройке ОС.
|
||||
============================================================ */
|
||||
|
||||
/* ---------- 1. Токены ---------- */
|
||||
:root{
|
||||
color-scheme: light dark;
|
||||
|
||||
--font: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
|
||||
--mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
/* поверхности и текст (light) */
|
||||
--bg:#f4f6f8;
|
||||
--surface:#ffffff;
|
||||
--surface-2:#eef1f5;
|
||||
--surface-3:#e6eaf0;
|
||||
--border:#e2e6ec;
|
||||
--border-strong:#d2d8e0;
|
||||
--text:#1a1f26;
|
||||
--text-dim:#5d6772;
|
||||
--text-faint:#8a939e;
|
||||
|
||||
/* акцент */
|
||||
--accent:#2f6fed;
|
||||
--accent-hover:#235fd8;
|
||||
--accent-weak:#e7f0fe;
|
||||
--accent-border:#bcd4fb;
|
||||
--on-accent:#ffffff;
|
||||
|
||||
/* семантика статусов (5 групп) */
|
||||
--st-prog:#2f6fed; --st-prog-bg:#e7f0fe; /* в работе: качается / скачано */
|
||||
--st-recog:#7c5cdb; --st-recog-bg:#efeafc; /* распознаётся */
|
||||
--st-link:#0d9488; --st-link-bg:#dcf5f1; /* раскладка */
|
||||
--st-wait:#c98a04; --st-wait-bg:#fdf3d7; /* ждёт человека: ревью / отложено */
|
||||
--st-ok:#1f9d57; --st-ok-bg:#dcf4e6; /* успех: готово */
|
||||
--st-warn:#d97315; --st-warn-bg:#fdeede; /* проблема: застряло / нет цели */
|
||||
--st-err:#d8443c; --st-err-bg:#fce4e2; /* ошибка: failed / потеряно */
|
||||
--st-mute:#6b7280; --st-mute-bg:#eceef1; /* терминальное: отменено/откат/удалено */
|
||||
|
||||
/* радиусы и тени */
|
||||
--r-sm:6px; --r-md:9px; --r-lg:14px; --r-pill:999px;
|
||||
--shadow-sm:0 1px 2px rgba(20,30,50,.06);
|
||||
--shadow-md:0 3px 12px rgba(20,30,50,.10);
|
||||
--shadow-lg:0 8px 28px rgba(20,30,50,.16);
|
||||
|
||||
/* шкала отступов */
|
||||
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px; --sp-7:48px;
|
||||
|
||||
/* типографика */
|
||||
--fs-xs:12px; --fs-sm:13px; --fs-base:15px; --fs-md:16px; --fs-lg:20px; --fs-xl:24px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root{
|
||||
--bg:#121519;
|
||||
--surface:#1a1e24;
|
||||
--surface-2:#22272e;
|
||||
--surface-3:#2a3038;
|
||||
--border:#2b323a;
|
||||
--border-strong:#3a424c;
|
||||
--text:#e6e9ed;
|
||||
--text-dim:#9aa4b0;
|
||||
--text-faint:#6b7682;
|
||||
|
||||
--accent:#5b8def;
|
||||
--accent-hover:#6f9cf3;
|
||||
--accent-weak:#1a2a45;
|
||||
--accent-border:#33507f;
|
||||
--on-accent:#0e1420;
|
||||
|
||||
--st-prog:#6fa0f4; --st-prog-bg:#172843;
|
||||
--st-recog:#a791ea; --st-recog-bg:#251f3c;
|
||||
--st-link:#36c2b0; --st-link-bg:#0f2e2b;
|
||||
--st-wait:#e0b13c; --st-wait-bg:#352a10;
|
||||
--st-ok:#46c47e; --st-ok-bg:#13311f;
|
||||
--st-warn:#ec9a4e; --st-warn-bg:#352213;
|
||||
--st-err:#ef6a62; --st-err-bg:#37201e;
|
||||
--st-mute:#8b95a1; --st-mute-bg:#262b31;
|
||||
|
||||
--shadow-sm:0 1px 2px rgba(0,0,0,.3);
|
||||
--shadow-md:0 3px 12px rgba(0,0,0,.4);
|
||||
--shadow-lg:0 8px 28px rgba(0,0,0,.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 2. База ---------- */
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{
|
||||
margin:0;background:var(--bg);color:var(--text);
|
||||
font-family:var(--font);font-size:var(--fs-base);line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
|
||||
}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
.mono{font-family:var(--mono)}
|
||||
.muted{color:var(--text-dim)}
|
||||
.faint{color:var(--text-faint)}
|
||||
.nowrap{white-space:nowrap}
|
||||
|
||||
/* ---------- 3. Layout + шапка ---------- */
|
||||
.app-header{
|
||||
position:sticky;top:0;z-index:20;
|
||||
background:color-mix(in srgb,var(--surface) 88%,transparent);
|
||||
backdrop-filter:saturate(1.4) blur(10px);
|
||||
border-bottom:1px solid var(--border);
|
||||
}
|
||||
.app-header .bar{
|
||||
max-width:980px;margin:0 auto;padding:0 var(--sp-4);
|
||||
display:flex;align-items:center;gap:var(--sp-4);height:56px;
|
||||
}
|
||||
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:var(--fs-md);
|
||||
letter-spacing:-.01em;color:var(--text)}
|
||||
.brand:hover{text-decoration:none}
|
||||
.brand .logo{width:22px;height:22px;border-radius:6px;background:var(--accent);
|
||||
display:grid;place-items:center;color:var(--on-accent);font-size:13px;flex:none}
|
||||
.nav{display:flex;gap:2px;margin-left:var(--sp-2)}
|
||||
.nav a{padding:6px 11px;border-radius:var(--r-sm);font-size:var(--fs-sm);font-weight:500;
|
||||
color:var(--text-dim)}
|
||||
.nav a:hover{background:var(--surface-2);color:var(--text);text-decoration:none}
|
||||
.nav a.active{background:var(--accent-weak);color:var(--accent)}
|
||||
.header-spacer{flex:1}
|
||||
|
||||
.dep-badge{display:inline-flex;align-items:center;gap:6px;font-size:var(--fs-xs);font-weight:500;
|
||||
padding:4px 10px;border-radius:var(--r-pill);background:var(--st-err-bg);color:var(--st-err);
|
||||
border:1px solid color-mix(in srgb,var(--st-err) 30%,transparent)}
|
||||
.dep-badge::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
|
||||
.dep-badge.ok{background:var(--st-ok-bg);color:var(--st-ok);
|
||||
border-color:color-mix(in srgb,var(--st-ok) 30%,transparent)}
|
||||
|
||||
.main{max-width:980px;margin:0 auto;padding:var(--sp-5) var(--sp-4) var(--sp-7)}
|
||||
.page-title{font-size:var(--fs-xl);font-weight:700;letter-spacing:-.02em;margin:0 0 var(--sp-1)}
|
||||
.page-sub{color:var(--text-dim);margin:0 0 var(--sp-5)}
|
||||
|
||||
/* ---------- 4. Кнопки ---------- */
|
||||
.btn{
|
||||
display:inline-flex;align-items:center;justify-content:center;gap:6px;
|
||||
font:inherit;font-weight:600;font-size:var(--fs-sm);line-height:1;
|
||||
padding:9px 15px;border-radius:var(--r-md);
|
||||
border:1px solid var(--border-strong);background:var(--surface);color:var(--text);
|
||||
cursor:pointer;white-space:nowrap;transition:background .12s,border-color .12s,opacity .12s;
|
||||
}
|
||||
.btn:hover{background:var(--surface-2)}
|
||||
.btn:focus-visible{outline:none;box-shadow:0 0 0 3px var(--accent-weak);border-color:var(--accent)}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.btn-primary{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}
|
||||
.btn-primary:hover{background:var(--accent-hover)}
|
||||
.btn-ghost{background:transparent;border-color:transparent;color:var(--text-dim)}
|
||||
.btn-ghost:hover{background:var(--surface-2);color:var(--text)}
|
||||
.btn-danger{color:var(--st-err);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)}
|
||||
.btn-danger:hover{background:var(--st-err-bg)}
|
||||
.btn-sm{padding:6px 11px;font-size:var(--fs-xs);font-weight:500}
|
||||
.btn-lg{padding:11px 20px;font-size:var(--fs-base)}
|
||||
.btn-block{width:100%}
|
||||
|
||||
/* ---------- 5. Поля ввода ---------- */
|
||||
.input,.textarea,.select{
|
||||
font:inherit;width:100%;color:var(--text);background:var(--surface);
|
||||
border:1px solid var(--border-strong);border-radius:var(--r-md);
|
||||
padding:9px 12px;transition:border-color .12s,box-shadow .12s;
|
||||
}
|
||||
.textarea{resize:vertical;min-height:64px;line-height:1.5}
|
||||
.input::placeholder,.textarea::placeholder{color:var(--text-faint)}
|
||||
.input:focus,.textarea:focus,.select:focus{
|
||||
outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.input.mono,.field-mono{font-family:var(--mono);font-size:var(--fs-sm)}
|
||||
.label{display:block;font-size:var(--fs-xs);font-weight:600;color:var(--text-dim);
|
||||
margin-bottom:5px;text-transform:uppercase;letter-spacing:.04em}
|
||||
.field{margin-bottom:var(--sp-3)}
|
||||
|
||||
/* единое окно добавления */
|
||||
.add-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);
|
||||
padding:var(--sp-4);box-shadow:var(--shadow-sm);margin-bottom:var(--sp-5)}
|
||||
.add-row{display:flex;gap:var(--sp-2)}
|
||||
.add-row .input{flex:1}
|
||||
.add-hint{margin:9px 2px 0;font-size:var(--fs-xs);color:var(--text-faint)}
|
||||
|
||||
/* ---------- 6. Карточка загрузки ---------- */
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);
|
||||
padding:var(--sp-4) var(--sp-5);box-shadow:var(--shadow-sm)}
|
||||
.card + .card{margin-top:var(--sp-3)}
|
||||
.card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:var(--sp-3)}
|
||||
.card-title{font-weight:600;font-size:var(--fs-md);line-height:1.3;letter-spacing:-.005em;
|
||||
word-break:break-word}
|
||||
.hash-row{display:flex;align-items:center;gap:8px;margin-top:7px;color:var(--text-dim);
|
||||
font-size:var(--fs-sm);flex-wrap:wrap}
|
||||
.hash{font-family:var(--mono);font-size:var(--fs-sm)}
|
||||
.copy-btn{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--border);
|
||||
background:var(--surface-2);border-radius:var(--r-sm);cursor:pointer;font:inherit;
|
||||
font-size:var(--fs-xs);padding:2px 8px;color:var(--text-dim);transition:background .12s}
|
||||
.copy-btn:hover{background:var(--surface-3);color:var(--text)}
|
||||
.copy-btn.copied{color:var(--st-ok);border-color:color-mix(in srgb,var(--st-ok) 35%,transparent)}
|
||||
.card-foot{display:flex;justify-content:space-between;align-items:center;gap:var(--sp-3);
|
||||
margin-top:var(--sp-4);flex-wrap:wrap}
|
||||
.card-meta{font-size:var(--fs-sm);color:var(--text-faint)}
|
||||
.card-acts{display:flex;gap:var(--sp-2);flex-wrap:wrap}
|
||||
|
||||
/* прогресс */
|
||||
.progress{height:5px;background:var(--surface-2);border-radius:3px;overflow:hidden;margin-top:10px}
|
||||
.progress i{display:block;height:100%;background:var(--st-prog);border-radius:3px;transition:width .3s}
|
||||
|
||||
/* ---------- 7. Спойлер ---------- */
|
||||
details.spoiler{margin-top:var(--sp-3)}
|
||||
.spoiler summary{cursor:pointer;color:var(--accent);font-size:var(--fs-sm);
|
||||
list-style:none;display:inline-flex;align-items:center;gap:5px;user-select:none}
|
||||
.spoiler summary::-webkit-details-marker{display:none}
|
||||
.spoiler summary::before{content:"▸";font-size:10px;transition:transform .12s}
|
||||
.spoiler[open] summary::before{transform:rotate(90deg)}
|
||||
.spoiler .spoiler-body{margin-top:9px;padding:11px 13px;background:var(--surface-2);
|
||||
border:1px solid var(--border);border-radius:var(--r-sm);font-size:var(--fs-sm);
|
||||
color:var(--text-dim);line-height:1.55}
|
||||
|
||||
/* ---------- 8. Бейдж состояния ---------- */
|
||||
.badge{display:inline-flex;align-items:center;gap:6px;font-size:var(--fs-xs);font-weight:500;
|
||||
padding:3px 10px 3px 9px;border-radius:var(--r-pill);border:1px solid transparent;
|
||||
white-space:nowrap;line-height:1.5}
|
||||
.badge::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--_c);flex:none}
|
||||
.badge{color:var(--text);background:var(--_bg);border-color:color-mix(in srgb,var(--_c) 30%,transparent)}
|
||||
.st-downloading,.st-completed{--_c:var(--st-prog);--_bg:var(--st-prog-bg)}
|
||||
.st-recognizing{--_c:var(--st-recog);--_bg:var(--st-recog-bg)}
|
||||
.st-linking{--_c:var(--st-link);--_bg:var(--st-link-bg)}
|
||||
.st-review,.st-deferred{--_c:var(--st-wait);--_bg:var(--st-wait-bg)}
|
||||
.st-done{--_c:var(--st-ok);--_bg:var(--st-ok-bg)}
|
||||
.st-stuck,.st-target_missing{--_c:var(--st-warn);--_bg:var(--st-warn-bg)}
|
||||
.st-failed,.st-orphaned{--_c:var(--st-err);--_bg:var(--st-err-bg)}
|
||||
.st-cancelled,.st-reverted,.st-deleted{--_c:var(--st-mute);--_bg:var(--st-mute-bg)}
|
||||
|
||||
/* ---------- 9. Панель фильтров ---------- */
|
||||
.toolbar{display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-4);flex-wrap:wrap}
|
||||
.search{position:relative;flex:1;min-width:180px}
|
||||
.search .input{padding-left:34px}
|
||||
.search .ico{position:absolute;left:11px;top:50%;transform:translateY(-50%);
|
||||
color:var(--text-faint);font-size:14px;pointer-events:none}
|
||||
.filter-chips{display:flex;gap:6px;flex-wrap:wrap}
|
||||
.chip{font:inherit;font-size:var(--fs-xs);font-weight:500;padding:6px 11px;border-radius:var(--r-pill);
|
||||
border:1px solid var(--border-strong);background:var(--surface);color:var(--text-dim);
|
||||
cursor:pointer;transition:all .12s}
|
||||
.chip:hover{background:var(--surface-2);color:var(--text)}
|
||||
.chip.active{background:var(--accent-weak);color:var(--accent);border-color:var(--accent-border)}
|
||||
.chip .n{opacity:.7;margin-left:3px}
|
||||
.toggle-line{display:flex;align-items:center;gap:8px;font-size:var(--fs-sm);color:var(--text-dim);
|
||||
margin-bottom:var(--sp-4)}
|
||||
.toggle-line label{display:inline-flex;align-items:center;gap:7px;cursor:pointer}
|
||||
|
||||
/* пагинация */
|
||||
.pager{display:flex;align-items:center;justify-content:center;gap:var(--sp-2);
|
||||
margin-top:var(--sp-5);font-size:var(--fs-sm);color:var(--text-dim)}
|
||||
.pager .btn{min-width:36px}
|
||||
|
||||
/* ---------- 10. Секция ревью ---------- */
|
||||
.section{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);
|
||||
padding:var(--sp-5);margin-bottom:var(--sp-4);box-shadow:var(--shadow-sm)}
|
||||
.section-head{display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-4)}
|
||||
.section-head h2{font-size:var(--fs-md);font-weight:600;margin:0;letter-spacing:-.005em}
|
||||
.section-head .hint{font-size:var(--fs-xs);color:var(--text-faint);font-weight:400}
|
||||
|
||||
/* шапка ревью */
|
||||
.review-head{display:flex;justify-content:space-between;align-items:flex-start;gap:var(--sp-4);
|
||||
flex-wrap:wrap;margin-bottom:var(--sp-2)}
|
||||
.review-title{font-size:var(--fs-lg);font-weight:700;letter-spacing:-.01em;
|
||||
font-family:var(--mono);word-break:break-word}
|
||||
.reason-line{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:var(--fs-sm);
|
||||
color:var(--text-dim);margin-top:var(--sp-1)}
|
||||
.reason-tag{background:var(--st-wait-bg);color:var(--st-wait);padding:2px 9px;border-radius:var(--r-pill);
|
||||
font-size:var(--fs-xs);font-weight:500;border:1px solid color-mix(in srgb,var(--st-wait) 30%,transparent)}
|
||||
.conf{display:inline-flex;align-items:center;gap:7px}
|
||||
.conf-bar{width:84px;height:6px;border-radius:3px;background:var(--surface-3);overflow:hidden}
|
||||
.conf-bar i{display:block;height:100%;border-radius:3px;background:var(--st-wait)}
|
||||
|
||||
/* тип: фильм/сериал — сегменты */
|
||||
.segment{display:inline-flex;border:1px solid var(--border-strong);border-radius:var(--r-md);
|
||||
overflow:hidden;background:var(--surface)}
|
||||
.segment label{padding:7px 16px;font-size:var(--fs-sm);font-weight:500;cursor:pointer;
|
||||
color:var(--text-dim);border-right:1px solid var(--border)}
|
||||
.segment label:last-child{border-right:none}
|
||||
.segment input{position:absolute;opacity:0;pointer-events:none}
|
||||
.segment input:checked + label,.segment label.on{background:var(--accent);color:var(--on-accent)}
|
||||
|
||||
/* сетка полей догадки */
|
||||
.guess-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--sp-3)}
|
||||
|
||||
/* ---------- 11. Кандидаты метабаз ---------- */
|
||||
.cand-list{display:flex;flex-direction:column;gap:var(--sp-2)}
|
||||
.cand{display:flex;align-items:center;gap:var(--sp-3);padding:11px var(--sp-4);
|
||||
border:1px solid var(--border-strong);border-radius:var(--r-md);background:var(--surface);
|
||||
cursor:pointer;transition:border-color .12s,background .12s}
|
||||
.cand:hover{background:var(--surface-2)}
|
||||
.cand.selected{border-color:var(--accent);background:var(--accent-weak);
|
||||
box-shadow:0 0 0 1px var(--accent) inset}
|
||||
.cand .radio{width:18px;height:18px;border-radius:50%;border:2px solid var(--border-strong);
|
||||
flex:none;display:grid;place-items:center;transition:border-color .12s}
|
||||
.cand.selected .radio{border-color:var(--accent)}
|
||||
.cand.selected .radio::after{content:"";width:9px;height:9px;border-radius:50%;background:var(--accent)}
|
||||
.cand .body{flex:1;min-width:0}
|
||||
.cand .name{font-weight:600;font-size:var(--fs-sm);display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.cand .sub{font-size:var(--fs-xs);color:var(--text-faint);margin-top:2px;font-family:var(--mono)}
|
||||
.prov{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;
|
||||
padding:2px 7px;border-radius:var(--r-sm);background:var(--surface-3);color:var(--text-dim)}
|
||||
.prov.tmdb{background:#0d253f;color:#5cc8a8}
|
||||
.prov.tvdb{background:#1a4029;color:#7fd99f}
|
||||
.prov.nn{background:var(--st-recog-bg);color:var(--st-recog)}
|
||||
.cand.nn-cand{border-style:dashed}
|
||||
.cand.nn-cand.selected{border-style:solid}
|
||||
.ext-link{font-size:var(--fs-xs);color:var(--accent);white-space:nowrap}
|
||||
|
||||
/* ручной ввод / без базы */
|
||||
.cand-extra{display:flex;gap:var(--sp-2);margin-top:var(--sp-3);flex-wrap:wrap;align-items:center}
|
||||
.cand-extra .input{flex:1;min-width:120px}
|
||||
|
||||
/* ---------- 12. Таблицы ---------- */
|
||||
.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:var(--r-md)}
|
||||
table.tbl{width:100%;border-collapse:collapse;font-size:var(--fs-sm);min-width:560px}
|
||||
table.tbl th{text-align:left;font-weight:600;font-size:var(--fs-xs);text-transform:uppercase;
|
||||
letter-spacing:.04em;color:var(--text-dim);padding:9px var(--sp-3);
|
||||
border-bottom:1px solid var(--border);background:var(--surface-2);white-space:nowrap}
|
||||
table.tbl td{padding:8px var(--sp-3);border-bottom:1px solid var(--border);vertical-align:middle}
|
||||
table.tbl tr:last-child td{border-bottom:none}
|
||||
table.tbl tr.ignored td{opacity:.45}
|
||||
table.tbl tr.invalid{background:var(--st-err-bg)}
|
||||
table.tbl .fname{font-family:var(--mono);font-size:var(--fs-xs);word-break:break-all}
|
||||
table.tbl .link-to{display:flex;align-items:baseline;gap:6px;margin-top:5px;
|
||||
font-family:var(--mono);font-size:var(--fs-xs);flex-wrap:wrap}
|
||||
table.tbl .link-to .arr{color:var(--text-faint);flex:none}
|
||||
table.tbl .link-to .dir{color:var(--text-faint)}
|
||||
table.tbl .link-to .src{color:var(--st-ok)}
|
||||
table.tbl .link-to .no-link{color:var(--text-faint);font-style:italic;font-family:var(--font)}
|
||||
table.tbl .se-edit{display:inline-flex;align-items:center;gap:4px;margin-right:6px;
|
||||
font-family:var(--font);color:var(--text-faint);font-size:var(--fs-xs)}
|
||||
table.tbl .se-edit .se-input{width:42px}
|
||||
table.tbl .size{font-family:var(--mono);color:var(--text-dim);white-space:nowrap;text-align:right}
|
||||
.cell-num{width:30px;color:var(--text-faint);font-family:var(--mono);text-align:right}
|
||||
.se-input{width:46px;font-family:var(--mono);text-align:center;padding:5px 6px;
|
||||
border:1px solid var(--border-strong);border-radius:var(--r-sm);background:var(--surface);
|
||||
color:var(--text);font-size:var(--fs-sm)}
|
||||
.se-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-weak)}
|
||||
.role-select{font:inherit;font-size:var(--fs-xs);padding:4px 6px;border-radius:var(--r-sm);
|
||||
border:1px solid var(--border-strong);background:var(--surface);color:var(--text)}
|
||||
.tbl-actions{display:flex;gap:var(--sp-2);margin-top:var(--sp-3);flex-wrap:wrap;align-items:center}
|
||||
.validation{font-size:var(--fs-xs);margin-top:var(--sp-2);display:flex;gap:var(--sp-3);flex-wrap:wrap}
|
||||
.validation .ok{color:var(--st-ok)}
|
||||
.validation .bad{color:var(--st-err)}
|
||||
|
||||
/* ---------- 13. Превью раскладки ---------- */
|
||||
.preview-list{font-family:var(--mono);font-size:var(--fs-sm);line-height:1.9;
|
||||
background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r-md);
|
||||
padding:var(--sp-3) var(--sp-4);overflow-x:auto}
|
||||
.preview-list .pth{color:var(--text)}
|
||||
.preview-list .pth .dir{color:var(--text-faint)}
|
||||
.preview-list .pth .src{color:var(--st-ok)}
|
||||
.preview-list .ref{color:var(--text-faint)}
|
||||
.preview-empty{color:var(--text-faint);font-style:italic;font-family:var(--font)}
|
||||
|
||||
/* ---------- 14. Уточнение ---------- */
|
||||
.hint-list{display:flex;flex-direction:column;gap:var(--sp-2);margin-bottom:var(--sp-3)}
|
||||
.hint-item{display:flex;gap:9px;align-items:flex-start;font-size:var(--fs-sm);
|
||||
padding:9px 12px;background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r-sm)}
|
||||
.hint-item .idx{font-family:var(--mono);font-size:var(--fs-xs);color:var(--text-faint);flex:none}
|
||||
.hint-item .txt{flex:1}
|
||||
.hint-item time{font-size:var(--fs-xs);color:var(--text-faint);white-space:nowrap}
|
||||
.refine-row{display:flex;gap:var(--sp-2);margin-top:var(--sp-3);flex-wrap:wrap}
|
||||
|
||||
/* ---------- 15. Нижняя панель действий ---------- */
|
||||
.action-bar{position:sticky;bottom:0;z-index:10;margin-top:var(--sp-5);
|
||||
background:color-mix(in srgb,var(--surface) 92%,transparent);
|
||||
backdrop-filter:saturate(1.4) blur(10px);
|
||||
border:1px solid var(--border);border-radius:var(--r-lg);
|
||||
padding:var(--sp-3) var(--sp-4);box-shadow:var(--shadow-md);
|
||||
display:flex;align-items:center;gap:var(--sp-3);flex-wrap:wrap}
|
||||
.action-bar .grow{flex:1;font-size:var(--fs-sm);color:var(--text-dim);min-width:140px}
|
||||
.action-bar .btn-lg{padding:11px 22px}
|
||||
|
||||
/* плашка результата после применения */
|
||||
.result-banner{background:var(--st-ok-bg);border:1px solid color-mix(in srgb,var(--st-ok) 35%,transparent);
|
||||
border-radius:var(--r-lg);padding:var(--sp-4) var(--sp-5);margin-bottom:var(--sp-4)}
|
||||
.result-banner h2{margin:0 0 var(--sp-2);font-size:var(--fs-md);color:var(--st-ok);
|
||||
display:flex;align-items:center;gap:8px}
|
||||
.result-banner ul{margin:0;padding-left:18px;font-family:var(--mono);font-size:var(--fs-sm);
|
||||
color:var(--text);line-height:1.9}
|
||||
|
||||
/* ---------- 16. Прочее ---------- */
|
||||
.empty-state{text-align:center;padding:var(--sp-7) var(--sp-4);color:var(--text-faint)}
|
||||
.empty-state .big{font-size:32px;margin-bottom:var(--sp-3)}
|
||||
.divider{height:1px;background:var(--border);margin:var(--sp-4) 0;border:none}
|
||||
.back-link{display:inline-flex;align-items:center;gap:6px;font-size:var(--fs-sm);
|
||||
color:var(--text-dim);margin-bottom:var(--sp-3)}
|
||||
.back-link:hover{color:var(--accent)}
|
||||
|
||||
/* ---------- 17. Страница загрузки ---------- */
|
||||
.detail-grid{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:var(--sp-4);align-items:start}
|
||||
|
||||
/* распознано как — карточка с постером */
|
||||
.meta-card{display:flex;gap:var(--sp-4);align-items:flex-start}
|
||||
.poster{flex:none;width:104px;height:156px;border-radius:var(--r-md);overflow:hidden;
|
||||
border:1px solid var(--border);
|
||||
background-image:repeating-linear-gradient(135deg,var(--surface-2) 0 9px,var(--surface-3) 9px 18px);
|
||||
display:grid;place-items:center}
|
||||
.poster span{font-family:var(--mono);font-size:10px;color:var(--text-faint);
|
||||
background:var(--surface);padding:2px 6px;border-radius:4px}
|
||||
.meta-body{flex:1;min-width:0}
|
||||
.meta-body .h{font-size:var(--fs-lg);font-weight:700;letter-spacing:-.01em;margin:0 0 2px;line-height:1.2}
|
||||
.meta-body .o{font-family:var(--mono);font-size:var(--fs-sm);color:var(--text-faint);margin:0 0 var(--sp-3)}
|
||||
|
||||
/* key-value список */
|
||||
.kv{display:grid;grid-template-columns:auto 1fr;gap:7px var(--sp-4);font-size:var(--fs-sm);margin:0}
|
||||
.kv dt{color:var(--text-faint);white-space:nowrap}
|
||||
.kv dd{margin:0;color:var(--text);text-align:right;font-variant-numeric:tabular-nums}
|
||||
.kv dd.mono{font-family:var(--mono)}
|
||||
|
||||
/* плитки статов */
|
||||
.stat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:var(--sp-3)}
|
||||
.stat{background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r-md);
|
||||
padding:var(--sp-3) var(--sp-4)}
|
||||
.stat .v{font-size:var(--fs-lg);font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
|
||||
.stat .v.mono{font-family:var(--mono);font-size:var(--fs-md)}
|
||||
.stat .k{font-size:var(--fs-xs);color:var(--text-faint);margin-top:2px}
|
||||
.stat .v.good{color:var(--st-ok)}
|
||||
|
||||
/* таймлайн истории состояний */
|
||||
.timeline{position:relative;margin:0;padding:0;list-style:none}
|
||||
.timeline::before{content:"";position:absolute;left:6px;top:6px;bottom:8px;width:2px;background:var(--border)}
|
||||
.tl-item{position:relative;padding:0 0 var(--sp-4) var(--sp-5);font-size:var(--fs-sm)}
|
||||
.tl-item:last-child{padding-bottom:0}
|
||||
.tl-item::before{content:"";position:absolute;left:0;top:4px;width:14px;height:14px;border-radius:50%;
|
||||
background:var(--surface);border:2px solid var(--border-strong)}
|
||||
.tl-item.is-done::before{background:var(--st-ok);border-color:var(--st-ok)}
|
||||
.tl-item.is-now::before{background:var(--accent);border-color:var(--accent);
|
||||
box-shadow:0 0 0 4px var(--accent-weak)}
|
||||
.tl-item .tl-t{font-weight:600;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.tl-item time{color:var(--text-faint);font-size:var(--fs-xs);font-variant-numeric:tabular-nums}
|
||||
.tl-item .tl-note{color:var(--text-dim);margin-top:2px}
|
||||
|
||||
/* ---------- 18. Адаптив ---------- */
|
||||
@media (max-width:640px){
|
||||
.main{padding:var(--sp-4) var(--sp-3) var(--sp-6)}
|
||||
.page-title{font-size:var(--fs-lg)}
|
||||
.add-row{flex-direction:column}
|
||||
.add-row .btn{width:100%}
|
||||
.card{padding:var(--sp-4)}
|
||||
.card-top{flex-direction:column}
|
||||
.review-head{flex-direction:column}
|
||||
.section{padding:var(--sp-4)}
|
||||
.action-bar{flex-direction:column;align-items:stretch}
|
||||
.action-bar .btn{width:100%}
|
||||
.action-bar .grow{text-align:center;order:-1}
|
||||
.nav a{padding:6px 9px}
|
||||
.toolbar{flex-direction:column;align-items:stretch}
|
||||
.detail-grid{grid-template-columns:1fr}
|
||||
.meta-card{flex-direction:column}
|
||||
.poster{width:88px;height:132px}
|
||||
}
|
||||
|
||||
/* доступность фокуса */
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||
.copy-btn:focus-visible,.chip:focus-visible{outline-offset:2px}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Клиентская мелочь без сборки и фреймворков.
|
||||
// htmx тянет серверные фрагменты, здесь — только то, что серверу знать не нужно.
|
||||
|
||||
// Копирование infohash в буфер обмена. На целевом деплое (домашний LAN по HTTP)
|
||||
// navigator.clipboard недоступен — secure context только на HTTPS/localhost,
|
||||
// поэтому есть fallback на execCommand, а индикация честная: «✓» лишь при успехе.
|
||||
function copyHash(btn, full) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(full).then(
|
||||
() => flashCopy(btn, true),
|
||||
() => flashCopy(btn, legacyCopy(full)),
|
||||
);
|
||||
} else {
|
||||
flashCopy(btn, legacyCopy(full));
|
||||
}
|
||||
}
|
||||
|
||||
function legacyCopy(text) {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function flashCopy(btn, ok) {
|
||||
const old = btn.dataset.label || btn.textContent;
|
||||
btn.dataset.label = old;
|
||||
btn.textContent = ok ? '✓ скопировано' : '✗ не вышло';
|
||||
btn.classList.toggle('copied', ok);
|
||||
setTimeout(() => { btn.textContent = old; btn.classList.remove('copied'); }, 1400);
|
||||
}
|
||||
|
||||
// --- Фильтр/поиск по списку загрузок (по уже отрисованным карточкам) ---
|
||||
|
||||
let activeFilter = 'all';
|
||||
|
||||
function setFilter(el) {
|
||||
document.querySelectorAll('#chips .chip').forEach((c) => c.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
activeFilter = el.dataset.f;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const searchEl = document.querySelector('#list-toolbar input[type=search]');
|
||||
const q = (searchEl ? searchEl.value : '').toLowerCase().trim();
|
||||
const showAllEl = document.getElementById('showAll');
|
||||
const showAll = showAllEl ? showAllEl.checked : false;
|
||||
let visible = 0;
|
||||
document.querySelectorAll('#list .card').forEach((card) => {
|
||||
const grp = card.dataset.group;
|
||||
const isDeleted = card.dataset.deleted === '1';
|
||||
let ok = true;
|
||||
if (isDeleted && !showAll) ok = false;
|
||||
if (ok && activeFilter !== 'all' && grp !== activeFilter) ok = false;
|
||||
if (ok && q && !(card.dataset.text || '').includes(q)) ok = false;
|
||||
card.style.display = ok ? '' : 'none';
|
||||
if (ok) visible += 1;
|
||||
});
|
||||
const empty = document.getElementById('empty');
|
||||
if (empty) empty.style.display = visible ? 'none' : '';
|
||||
}
|
||||
|
||||
// Применить начальный фильтр (скрыть удалённые) после загрузки.
|
||||
document.addEventListener('DOMContentLoaded', applyFilters);
|
||||
@@ -0,0 +1,90 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>jellybit — загрузка #{{.ID}}</title>
|
||||
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
|
||||
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
||||
<script src="{{asset "js/app.js"}}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{{template "header" ""}}
|
||||
|
||||
<main class="main">
|
||||
<a class="back-link" href="/">← ко всем загрузкам</a>
|
||||
|
||||
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
||||
|
||||
<!-- Шапка -->
|
||||
<div class="review-head" style="margin-bottom:var(--sp-4)">
|
||||
<div>
|
||||
<div class="review-title" style="font-size:var(--fs-xl)">{{.Title}}</div>
|
||||
<div class="hash-row" style="margin-top:9px">
|
||||
{{template "status_badge" .State}}
|
||||
{{if .Infohash}}<span class="hash">{{.InfohashShort}}</span>
|
||||
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.Infohash}}')">⧉ копировать</button>{{end}}
|
||||
</div>
|
||||
{{if .Note}}<p class="muted" style="margin:var(--sp-2) 0 0">{{.Note}}</p>{{end}}
|
||||
</div>
|
||||
<div class="card-acts" style="align-items:center">
|
||||
{{if .Reviewable}}<a class="btn btn-primary" href="/review/{{.ID}}">Ревью →</a>{{end}}
|
||||
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo"><button class="btn btn-danger" type="submit">↩ Откатить</button></form>{{end}}
|
||||
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink"><button class="btn" type="submit">Привязать заново</button></form>{{end}}
|
||||
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry"><button class="btn" type="submit">Повторить</button></form>{{end}}
|
||||
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-ghost" type="submit">Отменить</button></form>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Распознано как -->
|
||||
<div class="section">
|
||||
<div class="section-head"><h2>Распознано как</h2></div>
|
||||
{{if .HasPlan}}
|
||||
<div class="meta-card">
|
||||
<div class="meta-body">
|
||||
<p class="h">{{.RecTitle}}</p>
|
||||
{{if .OriginalTitle}}<p class="o">{{.OriginalTitle}}</p>{{end}}
|
||||
<dl class="kv" style="max-width:360px">
|
||||
<dt>Тип</dt><dd>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</dd>
|
||||
{{if .Year}}<dt>Год</dt><dd>{{.Year}}</dd>{{end}}
|
||||
<dt>База</dt><dd>{{if .Provider}}{{.Provider}} · <span class="mono">{{.ProviderID}}</span>{{else if .NoBase}}без базы{{else}}—{{end}}</dd>
|
||||
{{if .Confidence}}<dt>Уверенность</dt><dd class="mono">{{.Confidence}}</dd>{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="muted" style="margin:0">Ещё не распознано.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Файлы и раскладка -->
|
||||
{{if .HasPlan}}
|
||||
<div class="section">
|
||||
<div class="section-head"><h2>Файлы и раскладка</h2><span class="hint">файл источника → целевой хардлинк</span></div>
|
||||
{{template "layout_widget" .Files}}
|
||||
<p class="faint" style="font-size:var(--fs-xs);margin:var(--sp-3) 0 0">
|
||||
Целевые файлы связаны хардлинками — место не дублируется, исходник остаётся на раздаче.
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- История -->
|
||||
<div class="section">
|
||||
<div class="section-head"><h2>История</h2></div>
|
||||
<ul class="timeline">
|
||||
{{if .CreatedAt}}<li class="tl-item is-done"><div class="tl-t">Добавлено</div><time>{{.CreatedAt}}</time></li>{{end}}
|
||||
<li class="tl-item is-now"><div class="tl-t">{{badgeLabel .State}} <span class="badge st-{{.State}}">текущее</span></div>{{if .UpdatedAt}}<time>{{.UpdatedAt}}</time>{{end}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Контекст -->
|
||||
{{if .Context}}
|
||||
<div class="section" style="margin-bottom:0">
|
||||
<div class="section-head"><h2>Контекст</h2><span class="hint">сообщение при добавлении</span></div>
|
||||
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+86
-84
@@ -3,98 +3,100 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="5">
|
||||
<title>jellybit</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font-family: system-ui, sans-serif; max-width: 60rem; margin: 2rem auto; padding: 0 1rem; }
|
||||
h1 { margin-bottom: .25rem; }
|
||||
form.add { display: flex; gap: .5rem; flex-wrap: wrap; margin: 1rem 0; }
|
||||
form.add input[name=source] { flex: 1 1 24rem; }
|
||||
input, button { padding: .4rem .6rem; font-size: 1rem; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||
th, td { text-align: left; padding: .4rem .5rem; border-bottom: 1px solid #8884; vertical-align: top; }
|
||||
td.src { font-family: monospace; font-size: .85rem; word-break: break-all; }
|
||||
.err { color: #c0392b; }
|
||||
.state { font-size: .8rem; padding: .1rem .5rem; border-radius: 1rem; background: #8883; white-space: nowrap; }
|
||||
.state-completed { background: #2ecc7155; }
|
||||
.state-downloading { background: #3498db55; }
|
||||
.state-recognizing { background: #9b59b655; }
|
||||
.state-review { background: #f1c40f88; }
|
||||
.state-deferred { background: #f39c1255; }
|
||||
.state-linking { background: #1abc9c55; }
|
||||
.state-done { background: #2ecc7188; }
|
||||
.state-stuck { background: #f39c1255; }
|
||||
.state-failed { background: #e74c3c55; }
|
||||
.state-cancelled { background: #95a5a655; }
|
||||
.state-reverted { background: #95a5a655; }
|
||||
.state-target_missing { background: #e67e2255; }
|
||||
.state-orphaned { background: #e74c3c88; }
|
||||
.state-deleted { background: #95a5a655; }
|
||||
.actions { display: flex; gap: .4rem; flex-wrap: wrap; }
|
||||
a.button { display: inline-block; padding: .35rem .6rem; border: 1px solid #8886; border-radius: .3rem; text-decoration: none; }
|
||||
small { color: #8888; }
|
||||
</style>
|
||||
<title>jellybit — загрузки</title>
|
||||
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
|
||||
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
||||
<script src="{{asset "js/app.js"}}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>jellybit</h1>
|
||||
<p><small>обновляется каждые 5 с</small></p>
|
||||
|
||||
<form class="add" method="post" action="/ui/downloads">
|
||||
<input type="text" name="source" placeholder="magnet:?xt=urn:btih:…" required>
|
||||
<input type="text" name="context" placeholder="контекст (опц.)">
|
||||
<button type="submit">Добавить</button>
|
||||
{{template "header" "downloads"}}
|
||||
|
||||
<main class="main">
|
||||
<h1 class="page-title">Загрузки</h1>
|
||||
<p class="page-sub">Кинь magnet, ссылку, .torrent или сообщение бота — остальное jellybit сделает сам.</p>
|
||||
|
||||
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
||||
|
||||
<!-- Единое окно добавления -->
|
||||
<form class="add-box" method="post" action="/ui/downloads">
|
||||
<div class="add-row">
|
||||
<input class="input" type="text" name="source" autocomplete="off" required
|
||||
placeholder="magnet:?xt=urn:btih:… · ссылка · .torrent · или вставь текст бота">
|
||||
<button class="btn btn-primary btn-lg" type="submit">Добавить</button>
|
||||
</div>
|
||||
<p class="add-hint">Можно вставить целиком сообщение трекер-бота — magnet и контекст разберутся автоматически.</p>
|
||||
</form>
|
||||
|
||||
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
|
||||
{{if .Downloads}}
|
||||
<!-- Панель фильтров -->
|
||||
<div class="toolbar" id="list-toolbar">
|
||||
<div class="search">
|
||||
<span class="ico">⌕</span>
|
||||
<input class="input" type="search" placeholder="Поиск по названию или infohash…" oninput="applyFilters()">
|
||||
</div>
|
||||
<div class="filter-chips" id="chips">
|
||||
<button class="chip active" type="button" data-f="all" onclick="setFilter(this)">Все</button>
|
||||
<button class="chip" type="button" data-f="review" onclick="setFilter(this)">Ждут меня</button>
|
||||
<button class="chip" type="button" data-f="active" onclick="setFilter(this)">В работе</button>
|
||||
<button class="chip" type="button" data-f="done" onclick="setFilter(this)">Готово</button>
|
||||
<button class="chip" type="button" data-f="problem" onclick="setFilter(this)">Проблемы</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Источник</th><th>Контекст</th><th>Состояние</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="toggle-line">
|
||||
<label><input type="checkbox" id="showAll" onchange="applyFilters()"> Показать всё, включая удалённые</label>
|
||||
</div>
|
||||
|
||||
<!-- Список карточек -->
|
||||
<div id="list">
|
||||
{{range .Downloads}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td class="src" title="{{.Infohash}}">{{.Source}}</td>
|
||||
<td>{{.Context}}</td>
|
||||
<td>
|
||||
<span class="state state-{{.State}}">{{.State}}</span>
|
||||
{{if .Note}}<br><small>{{.Note}}</small>{{end}}
|
||||
{{if .Error}}<br><small>{{.Error}}</small>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
{{if .Reviewable}}
|
||||
<a class="button" href="/review/{{.ID}}">Ревью →</a>
|
||||
{{end}}
|
||||
{{if .Undoable}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/undo">
|
||||
<button type="submit">Откатить</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Relinkable}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/relink">
|
||||
<button type="submit">Привязать заново</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Retriable}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/retry">
|
||||
<button type="submit">Повторить</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if not .Terminal}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/cancel">
|
||||
<button type="submit">Отклонить</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<article class="card" data-group="{{.Group}}" data-text="{{.SearchText}}"{{if .Deleted}} data-deleted="1"{{end}}>
|
||||
<div class="card-top">
|
||||
<div>
|
||||
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
||||
{{if .Infohash}}
|
||||
<div class="hash-row">
|
||||
<span class="hash">{{.InfohashShort}}</span>
|
||||
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.Infohash}}')">⧉ копировать</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5"><small>пока пусто</small></td></tr>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "status_badge" .State}}
|
||||
</div>
|
||||
|
||||
{{if .Context}}
|
||||
<details class="spoiler">
|
||||
<summary>контекст</summary>
|
||||
<div class="spoiler-body">«{{.Context}}»</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
<div class="card-foot">
|
||||
<span class="card-meta">{{if .Note}}{{.Note}}{{else if .Error}}{{.Error}}{{end}}</span>
|
||||
<div class="card-acts">
|
||||
{{if .Reviewable}}<a class="btn btn-primary btn-sm" href="/review/{{.ID}}">Ревью →</a>{{end}}
|
||||
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo"><button class="btn btn-sm btn-ghost" type="submit">Откатить</button></form>{{end}}
|
||||
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink"><button class="btn btn-sm" type="submit">Привязать заново</button></form>{{end}}
|
||||
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry"><button class="btn btn-sm" type="submit">Повторить</button></form>{{end}}
|
||||
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-sm btn-danger" type="submit">{{if .Reviewable}}Отклонить{{else}}Отменить{{end}}</button></form>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="empty" style="display:none">
|
||||
<div class="big">🍿</div>
|
||||
Ничего не найдено. Поменяй фильтр или запрос.
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="big">🍿</div>
|
||||
Пока пусто. Добавь первую загрузку выше.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{{define "header"}}
|
||||
<header class="app-header">
|
||||
<div class="bar">
|
||||
<a class="brand" href="/"><span class="logo">jb</span>jellybit</a>
|
||||
<nav class="nav">
|
||||
<a href="/"{{if eq . "downloads"}} class="active"{{end}}>Загрузки</a>
|
||||
</nav>
|
||||
<span class="header-spacer"></span>
|
||||
</div>
|
||||
</header>
|
||||
{{end}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{define "layout_widget"}}
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th class="cell-num">#</th><th>файл источника → раскладка</th><th>роль</th></tr></thead>
|
||||
<tbody>
|
||||
{{range $i, $f := .}}
|
||||
<tr class="{{if $f.Ignored}}ignored{{end}}">
|
||||
<td class="cell-num">{{add $i 1}}</td>
|
||||
<td>
|
||||
<div class="fname">{{$f.Src}}</div>
|
||||
<div class="link-to">
|
||||
<span class="arr">→</span>
|
||||
{{if $f.Linked}}<span class="src">{{$f.Dst}}</span>{{else}}<span class="no-link">не разложен</span>{{end}}
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="badge {{if $f.Ignored}}st-cancelled{{else}}st-done{{end}}">{{$f.RoleLabel}}</span></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1 @@
|
||||
{{define "status_badge"}}<span class="badge st-{{.}}">{{badgeLabel .}}</span>{{end}}
|
||||
+125
-143
@@ -3,169 +3,151 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>jellybit · ревью #{{.ID}}</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font-family: system-ui, sans-serif; max-width: 70rem; margin: 2rem auto; padding: 0 1rem; }
|
||||
h1 { margin-bottom: .25rem; }
|
||||
a { color: inherit; }
|
||||
input, button, select, textarea { padding: .4rem .6rem; font-size: 1rem; font-family: inherit; }
|
||||
table { width: 100%; border-collapse: collapse; margin: .5rem 0 1rem; }
|
||||
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid #8884; vertical-align: top; }
|
||||
td.src, li.path { font-family: monospace; font-size: .85rem; word-break: break-all; }
|
||||
.err { color: #c0392b; }
|
||||
.state { font-size: .8rem; padding: .1rem .5rem; border-radius: 1rem; background: #8883; }
|
||||
.state-review { background: #f1c40f88; }
|
||||
.state-recognizing { background: #9b59b655; }
|
||||
.state-deferred { background: #f39c1255; }
|
||||
section { border: 1px solid #8883; border-radius: .5rem; padding: .75rem 1rem; margin: 1rem 0; }
|
||||
.reasons li { color: #b9770e; }
|
||||
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||
.actions { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1rem; }
|
||||
.actions form { display: inline; }
|
||||
.ignored td { opacity: .5; text-decoration: line-through; }
|
||||
ul.preview { margin: 0; padding-left: 1.2rem; }
|
||||
small { color: #8888; }
|
||||
textarea { width: 100%; box-sizing: border-box; }
|
||||
</style>
|
||||
<title>jellybit — ревью #{{.ID}}</title>
|
||||
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
|
||||
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
||||
<script src="{{asset "js/app.js"}}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<p><a href="/">← к списку</a></p>
|
||||
<h1>Ревью #{{.ID}} <span class="state state-{{.State}}">{{.State}}</span></h1>
|
||||
<p class="src"><small>{{.Source}}</small></p>
|
||||
{{if .Context}}<p>Контекст: «{{.Context}}»</p>{{end}}
|
||||
|
||||
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
|
||||
{{if .StateError}}<p class="err">{{.StateError}}</p>{{end}}
|
||||
{{template "header" ""}}
|
||||
|
||||
<main class="main">
|
||||
<a class="back-link" href="/">← ко всем загрузкам</a>
|
||||
|
||||
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
||||
{{if .StateError}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.StateError}}</h2></div>{{end}}
|
||||
|
||||
<!-- Шапка ревью -->
|
||||
<div class="review-head">
|
||||
<div>
|
||||
<div class="review-title">{{.Source}}</div>
|
||||
<div class="reason-line">
|
||||
{{template "status_badge" .State}}
|
||||
{{if .Reasons}}<span>Причины: {{range $i, $r := .Reasons}}{{if $i}} · {{end}}<b>{{$r}}</b>{{end}}</span>{{end}}
|
||||
{{if .Confidence}}<span class="conf">уверенность {{.Confidence}}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if eq .State "recognizing"}}
|
||||
<p>⏳ Идёт распознавание, обновите страницу через несколько секунд…</p>
|
||||
<p><a href="/review/{{.ID}}">Обновить</a></p>
|
||||
<div class="section">
|
||||
<p style="margin:0">⏳ Идёт распознавание — обновите страницу через несколько секунд.</p>
|
||||
<p style="margin:var(--sp-2) 0 0"><a href="/review/{{.ID}}">Обновить</a></p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Reasons}}
|
||||
<section>
|
||||
<strong>Причины ревью</strong>{{if .Confidence}} · уверенность {{.Confidence}}{{end}}
|
||||
<ul class="reasons">{{range .Reasons}}<li>{{.}}</li>{{end}}</ul>
|
||||
</section>
|
||||
<!-- Контекст -->
|
||||
{{if .Context}}
|
||||
<div class="section">
|
||||
<div class="section-head"><h2>Контекст</h2><span class="hint">исходное сообщение при добавлении</span></div>
|
||||
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .HasPlan}}
|
||||
<section>
|
||||
<strong>Догадка</strong>
|
||||
<p class="row">
|
||||
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
|
||||
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
|
||||
{{if .Year}}· Год: <b>{{.Year}}</b>{{end}}
|
||||
{{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{else if .NoBase}}· База: <b>без базы</b>{{end}}
|
||||
</p>
|
||||
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
|
||||
Переключить тип:
|
||||
<button name="type" value="movie" {{if not .IsSeries}}disabled{{end}}>фильм</button>
|
||||
<button name="type" value="series" {{if .IsSeries}}disabled{{end}}>сериал</button>
|
||||
<small>(пересоберёт план)</small>
|
||||
</form>
|
||||
</section>
|
||||
<!-- Догадка -->
|
||||
<div class="section">
|
||||
<div class="section-head"><h2>Догадка</h2></div>
|
||||
<div style="margin-bottom:var(--sp-4)">
|
||||
<span class="label">Тип</span>
|
||||
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type" style="display:flex;gap:var(--sp-2);align-items:center">
|
||||
<button class="btn btn-sm{{if not .IsSeries}} btn-primary{{end}}" name="type" value="movie"{{if not .IsSeries}} disabled{{end}}>фильм</button>
|
||||
<button class="btn btn-sm{{if .IsSeries}} btn-primary{{end}}" name="type" value="series"{{if .IsSeries}} disabled{{end}}>сериал</button>
|
||||
<span class="hint">переключение пересоберёт план</span>
|
||||
</form>
|
||||
</div>
|
||||
<div class="guess-grid">
|
||||
<div class="field"><span class="label">Название</span><div>{{.Title}}</div></div>
|
||||
{{if .OriginalTitle}}<div class="field"><span class="label">Ориг. название</span><div class="mono">{{.OriginalTitle}}</div></div>{{end}}
|
||||
{{if .Year}}<div class="field"><span class="label">Год</span><div>{{.Year}}</div></div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<strong>База метаданных</strong>
|
||||
{{if .Provider}}<p>Выбрано: <b>{{.Provider}}</b> {{.ProviderID}}</p>
|
||||
{{else if .NoBase}}<p>Выбрано: <b>без базы</b> (тег папки не ставится)</p>
|
||||
{{else}}<p><small>Матч не подтверждён — выберите кандидата, введите id или «без базы».</small></p>{{end}}
|
||||
<!-- Источник совпадения -->
|
||||
<div class="section">
|
||||
<div class="section-head"><h2>Источник совпадения</h2><span class="hint">выбери базу или оставь распознавание нейронкой</span></div>
|
||||
|
||||
{{if .Provider}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>{{.Provider}}</b> <span class="mono">{{.ProviderID}}</span></p>
|
||||
{{else if .NoBase}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>без базы</b> <span class="faint">(тег папки не ставится)</span></p>
|
||||
{{else}}<p style="margin:0 0 var(--sp-3)"><span class="faint">Матч не подтверждён — выбери кандидата, введи id или «без базы».</span></p>{{end}}
|
||||
|
||||
{{if .Candidates}}
|
||||
<table>
|
||||
<thead><tr><th>провайдер</th><th>название</th><th>год</th><th>id</th><th>ссылка</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<div class="cand-list">
|
||||
{{range .Candidates}}
|
||||
<tr>
|
||||
<td>{{.Provider}}</td>
|
||||
<td>{{.Title}}</td>
|
||||
<td>{{if .Year}}{{.Year}}{{end}}</td>
|
||||
<td class="src">{{.ProviderID}}</td>
|
||||
<td>{{if .URL}}<a href="{{.URL}}" target="_blank" rel="noopener">{{.Provider}}</a>{{end}}</td>
|
||||
<td>
|
||||
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
|
||||
<input type="hidden" name="candidate_id" value="{{.ID}}">
|
||||
<button type="submit" {{if .Chosen}}disabled{{end}}>{{if .Chosen}}выбрано{{else}}выбрать{{end}}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<div class="cand{{if .Chosen}} selected{{end}}">
|
||||
<span class="radio"></span>
|
||||
<div class="body">
|
||||
<div class="name"><span class="prov {{.Provider}}">{{.Provider}}</span> {{.Title}}{{if .Year}} <span class="muted">· {{.Year}}</span>{{end}}</div>
|
||||
<div class="sub">id {{.ProviderID}}</div>
|
||||
</div>
|
||||
{{if .URL}}<a class="ext-link" href="{{.URL}}" target="_blank" rel="noopener">ссылка ↗</a>{{end}}
|
||||
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
|
||||
<input type="hidden" name="candidate_id" value="{{.ID}}">
|
||||
<button class="btn btn-sm{{if not .Chosen}} btn-primary{{end}}" type="submit"{{if .Chosen}} disabled{{end}}>{{if .Chosen}}выбрано{{else}}выбрать{{end}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form class="row" method="post" action="/ui/downloads/{{.ID}}/provider">
|
||||
Вручную:
|
||||
<select name="provider">
|
||||
<option value="tvdb">tvdb</option>
|
||||
<option value="tmdb">tmdb</option>
|
||||
<option value="imdb">imdb</option>
|
||||
<form class="cand-extra" method="post" action="/ui/downloads/{{.ID}}/provider">
|
||||
<select class="select" name="provider" style="max-width:120px">
|
||||
<option value="tmdb">TMDB</option>
|
||||
<option value="tvdb">TVDB</option>
|
||||
<option value="imdb">IMDb</option>
|
||||
</select>
|
||||
<input type="text" name="provider_id" placeholder="id (напр. 269613)" required>
|
||||
<button type="submit">задать id</button>
|
||||
<input class="input mono" name="provider_id" placeholder="ввести id вручную, напр. 60622" required>
|
||||
<button class="btn btn-sm" type="submit">Задать id</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/nobase" style="margin-top:.4rem">
|
||||
<button type="submit">Без базы</button>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/nobase" style="margin-top:var(--sp-2)">
|
||||
<button class="btn btn-sm btn-ghost" type="submit">Без базы</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<strong>Файлы → роль</strong>
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>файл</th><th>роль</th><th>S</th><th>E</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range $i, $f := .Files}}
|
||||
<tr {{if $f.Ignored}}class="ignored"{{end}}>
|
||||
<td>{{add $i 1}}</td>
|
||||
<td class="src">{{$f.Src}}</td>
|
||||
<td>{{$f.Role}}</td>
|
||||
<td>{{$f.Season}}</td>
|
||||
<td>{{$f.Episode}}</td>
|
||||
<td>
|
||||
{{if not $f.Ignored}}
|
||||
<form method="post" action="/ui/downloads/{{$.ID}}/ignore">
|
||||
<input type="hidden" name="src" value="{{$f.Src}}">
|
||||
<button type="submit">игнор</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{{if .Preview}}
|
||||
<section>
|
||||
<strong>Превью раскладки</strong> <small>(будут созданы хардлинки)</small>
|
||||
<ul class="preview">{{range .Preview}}<li class="path">{{.}}</li>{{end}}</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<section>
|
||||
<strong>Уточнить и перераспознать</strong>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/refine">
|
||||
<p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p>
|
||||
<button type="submit">🔁 Уточнить</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:.4rem">
|
||||
<button type="submit">🔄 Распознать заново</button>
|
||||
<small>(без новой подсказки — по уже накопленному контексту)</small>
|
||||
</form>
|
||||
{{if .Hints}}
|
||||
<p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
{{if .Preview}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/apply"><button type="submit">✅ Применить</button></form>
|
||||
{{end}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button type="submit">🕗 Позже</button></form>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button type="submit">❌ Отклонить</button></form>
|
||||
</div>
|
||||
|
||||
<!-- Раскладка -->
|
||||
<div class="section">
|
||||
<div class="section-head">
|
||||
<h2>Раскладка</h2>
|
||||
<span class="hint">файл источника → целевой хардлинк</span>
|
||||
</div>
|
||||
{{template "layout_widget" .Files}}
|
||||
<p class="faint" style="font-size:var(--fs-xs);margin:var(--sp-3) 0 0">
|
||||
Буквальные целевые пути. Применяются как хардлинки — исходник остаётся на раздаче.
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Уточнить и перераспознать -->
|
||||
<div class="section">
|
||||
<div class="section-head"><h2>Уточнить и перераспознать</h2></div>
|
||||
{{if .Hints}}
|
||||
<div class="hint-list">
|
||||
{{range $i, $h := .Hints}}
|
||||
<div class="hint-item"><span class="idx">#{{add $i 1}}</span><span class="txt">{{$h}}</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/refine">
|
||||
<textarea class="textarea" name="hint" placeholder="Например: «это Fargo, год сериала 2014, не 2015» или «sample.mkv не считать эпизодом»"></textarea>
|
||||
<div class="refine-row">
|
||||
<button class="btn btn-primary btn-sm" type="submit">🔁 Уточнить</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:var(--sp-2)">
|
||||
<button class="btn btn-sm" type="submit">🔄 Распознать заново</button>
|
||||
<span class="hint">без новой подсказки — по уже накопленному контексту</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Панель действий -->
|
||||
<div class="action-bar">
|
||||
<span class="grow">{{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}</span>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button class="btn btn-lg" type="submit">🕗 Позже</button></form>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-lg btn-danger" type="submit">❌ Отклонить</button></form>
|
||||
{{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+8
-3
@@ -1,9 +1,14 @@
|
||||
// Package web несёт встроенные (embed) ресурсы веб-UI: HTML-шаблоны.
|
||||
// Package web несёт встроенные (embed) ресурсы веб-UI: HTML-шаблоны и статику
|
||||
// (CSS, JS, шрифты, вендорные библиотеки). Бинарь самодостаточен — внешних
|
||||
// ресурсов времени выполнения нет.
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS — встроенные шаблоны (templates/*.html).
|
||||
// FS — встроенные ресурсы: templates/** (шаблоны и партиалы) и static/**
|
||||
// (авторские css/js + вендор htmx/шрифты под static/vendor). Вендор в репо не
|
||||
// хранится (gitignore); его добывает `task assets` по web/assets.manifest перед
|
||||
// сборкой, поэтому `task build`/`run` зависят от `assets`.
|
||||
//
|
||||
//go:embed templates/*.html
|
||||
//go:embed templates static
|
||||
var FS embed.FS
|
||||
|
||||
Reference in New Issue
Block a user