- IDEA.md: product vision from the user's perspective (input for design)
- Architecture, ADRs, data model, REST API overview
- Auth and roles, library storage (NFS, path regexes, scanner, search),
playback, recommendation system ("My Wave"), track upload queue
- Web, Android (offline, self-update), i18n, design system
- Testing strategy, Gitea Actions CI/CD, deployment, conventions, roadmap
- Clickable web/Android design prototypes and wave button variants
- .claude/launch.json to serve the prototypes locally
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1615 lines
193 KiB
HTML
1615 lines
193 KiB
HTML
<!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=Onest:wght@400;500;600;700&family=Unbounded:wght@500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0..1,0&display=block" rel="stylesheet">
|
||
<script src="pipu-data.js"></script>
|
||
<style>
|
||
:root{--bg:#05070D;--bg2:#080B14;--surface:#0B0F1A;--surface2:#121827;--surface3:#1A2236;--elev:#111726;--hover:rgba(120,150,255,.07);--line:rgba(130,160,255,.10);--line2:rgba(130,160,255,.18);--text:#EDF1FF;--text2:#A0ABC6;--text3:#737F9C;--accent:#2F80FF;--accent2:#5E9BFF;--accent-soft:rgba(47,128,255,.15);--violet:#9B7CFF;--violet-soft:rgba(124,92,255,.16);--ok:#2FD39A;--ok-soft:rgba(47,211,154,.14);--warn:#FFB547;--warn-soft:rgba(255,181,71,.14);--danger:#FF5C70;--danger-soft:rgba(255,92,112,.14);--overlay:rgba(3,5,10,.66);--shadow:0 24px 64px rgba(0,0,0,.55);--glass:rgba(12,16,28,.78);--glow:0 8px 28px rgba(47,128,255,.38);--surface-a:rgba(11,15,26,.70);--bar-a:rgba(6,9,16,.55);--amb-op:.8}
|
||
[data-theme="light"]{--bg:#F3F5FA;--bg2:#EBEEF5;--surface:#FFFFFF;--surface2:#F0F3F9;--surface3:#E4E9F3;--elev:#FFFFFF;--hover:rgba(30,60,140,.06);--line:rgba(20,35,80,.10);--line2:rgba(20,35,80,.17);--text:#0A0F1E;--text2:#4B5675;--text3:#677290;--accent:#1F6FF2;--accent2:#1F6FF2;--accent-soft:rgba(31,111,242,.10);--violet:#6A47F0;--violet-soft:rgba(106,71,240,.10);--ok:#0E9465;--ok-soft:rgba(14,148,101,.10);--warn:#A86200;--warn-soft:rgba(168,98,0,.10);--danger:#D32F47;--danger-soft:rgba(211,47,71,.09);--overlay:rgba(20,30,60,.32);--shadow:0 24px 64px rgba(20,30,60,.18);--glass:rgba(255,255,255,.84);--glow:0 8px 24px rgba(31,111,242,.28);--surface-a:rgba(255,255,255,.74);--bar-a:rgba(255,255,255,.55);--amb-op:.45}
|
||
[data-shell="classic"]{--side-w:240px;--gap:0px;--pad:0px;--pr:0px;--panel-bg:transparent;--panel-blur:none;--side-border:1px solid var(--line);--top-border:1px solid var(--line);--top-px:20px;--player-bg:var(--bar-a)}
|
||
[data-shell="floating"]{--side-w:232px;--gap:8px;--pad:8px;--pr:18px;--panel-bg:var(--surface-a);--panel-blur:blur(28px) saturate(1.2);--side-border:0;--top-border:0;--top-px:8px;--player-bg:var(--surface-a)}
|
||
[data-player="bar"]{--rows:60px minmax(0,1fr) 88px;--cpb:40px}
|
||
[data-player="island"]{--rows:60px minmax(0,1fr);--cpb:132px}
|
||
@keyframes pipuDrift1{0%{transform:translate(-6%,-4%) scale(1)}35%{transform:translate(45%,28%) scale(1.2)}70%{transform:translate(15%,60%) scale(.9)}100%{transform:translate(60%,-5%) scale(1.1)}}
|
||
@keyframes pipuDrift2{0%{transform:translate(4%,0) scale(1.05)}40%{transform:translate(-50%,40%) scale(.9)}75%{transform:translate(-20%,-25%) scale(1.2)}100%{transform:translate(-60%,15%) scale(1)}}
|
||
@keyframes pipuDrift3{0%{transform:translate(0,0) scale(.95)}30%{transform:translate(-40%,-45%) scale(1.15)}65%{transform:translate(45%,-25%) scale(1)}100%{transform:translate(10%,-55%) scale(1.2)}}
|
||
@keyframes pipuHue{0%{filter:hue-rotate(0deg)}50%{filter:hue-rotate(-35deg)}100%{filter:hue-rotate(0deg)}}
|
||
@keyframes pipuEq{0%{transform:scaleY(.25)}100%{transform:scaleY(1)}}
|
||
@keyframes pipuIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
||
body{margin:0;background:#05070D;font-family:Onest,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
||
a{color:var(--accent2);text-decoration:none}a:hover{color:var(--text)}
|
||
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
|
||
input,textarea,select{font:inherit;color:inherit}
|
||
i{font-family:'Material Symbols Rounded';font-style:normal;font-weight:normal;line-height:1;font-size:22px;display:inline-block;-webkit-font-smoothing:antialiased;font-feature-settings:'liga';user-select:none;flex-shrink:0}
|
||
::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:rgba(130,160,255,.18);border-radius:10px;border:3px solid transparent;background-clip:padding-box}
|
||
</style>
|
||
</helmet>
|
||
<div data-theme="{{ theme }}" data-shell="{{ shell }}" data-player="{{ playerV }}" style="height:100vh;background:var(--bg);color:var(--text);font-family:Onest,system-ui,sans-serif;position:relative;overflow:hidden">
|
||
{{ ambient }}
|
||
|
||
<sc-if value="{{ isAuth }}">
|
||
<div style="position:absolute;inset:0;z-index:1;display:flex;align-items:center;justify-content:center;overflow:hidden">
|
||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);opacity:.55">
|
||
<x-import component="WaveButton" from="./wave.js" variant="{{ waveVariant }}" playing="{{ false }}" interactive="{{ false }}" theme="{{ theme }}" size="{{ 620 }}" hint-size="620px,620px"></x-import>
|
||
</div>
|
||
<div style="position:absolute;top:24px;left:28px;display:flex;align-items:center;gap:10px">
|
||
<div style="width:28px;height:28px;border-radius:50%;background:radial-gradient(circle at 35% 30%, #9CC6FF, #2F80FF 55%, #3A1FA8);box-shadow:0 0 18px rgba(47,128,255,.55)"></div>
|
||
<div style="font-family:Unbounded;font-weight:600;font-size:19px;letter-spacing:-0.02em">pipu</div>
|
||
</div>
|
||
<div style="position:absolute;top:22px;right:24px;display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--glass);border:1px solid var(--line)">
|
||
<sc-for list="{{ langs }}" as="l" hint-placeholder-count="2">
|
||
<button onClick="{{ l.onClick }}" style="height:30px;padding:0 12px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
|
||
<sc-if value="{{ auth.login }}" hint-placeholder-val="{{ true }}">
|
||
<div style="position:relative;width:400px;max-width:calc(100% - 32px);display:flex;flex-direction:column;gap:20px;padding:36px;border-radius:24px;background:var(--glass);backdrop-filter:blur(28px) saturate(1.3);border:1px solid var(--line2);box-shadow:var(--shadow);box-sizing:border-box">
|
||
<div style="display:flex;flex-direction:column;gap:8px">
|
||
<div style="font-family:Unbounded;font-size:26px;font-weight:600;letter-spacing:-0.01em">Вход</div>
|
||
<div style="font-size:14px;line-height:1.5;color:var(--text2)">Учётные записи выдаёт администратор сервера</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:14px">
|
||
<label style="display:flex;flex-direction:column;gap:6px">
|
||
<span style="font-size:13px;font-weight:500;color:var(--text2)">Логин</span>
|
||
<input value="{{ f.login }}" onChange="{{ fh.login }}" onKeyDown="{{ onLoginKey }}" autocomplete="username" style="height:48px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)">
|
||
</label>
|
||
<label style="display:flex;flex-direction:column;gap:6px">
|
||
<span style="font-size:13px;font-weight:500;color:var(--text2)">Пароль</span>
|
||
<div style="position:relative;display:flex">
|
||
<input type="{{ pwType }}" value="{{ f.pass }}" onChange="{{ fh.pass }}" onKeyDown="{{ onLoginKey }}" autocomplete="current-password" style="flex:1;height:48px;padding:0 48px 0 14px;border-radius:12px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)">
|
||
<button onClick="{{ toggleShowPw }}" aria-label="Показать пароль" style="position:absolute;right:6px;top:6px;width:36px;height:36px;border-radius:8px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="color:var(--text)"><i style="font-size:20px">{{ pwIcon }}</i></button>
|
||
</div>
|
||
</label>
|
||
<sc-if value="{{ err.login }}">
|
||
<div style="display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:10px;background:var(--danger-soft);color:var(--danger);font-size:13px;font-weight:500"><i style="font-size:18px">error</i>{{ err.login }}</div>
|
||
</sc-if>
|
||
</div>
|
||
<button onClick="{{ onLogin }}" style="height:48px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:15px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)">Войти</button>
|
||
<div style="font-size:12px;line-height:1.5;color:var(--text3);text-align:center">Демо: <span style="font-family:'JetBrains Mono';color:var(--text2)">admin / admin</span> — первый вход администратора, пароль <span style="font-family:'JetBrains Mono';color:var(--text2)">reset</span> — после сброса</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ auth.setup }}">
|
||
<div style="position:relative;width:440px;max-width:calc(100% - 32px);display:flex;flex-direction:column;gap:20px;padding:36px;border-radius:24px;background:var(--glass);backdrop-filter:blur(28px) saturate(1.3);border:1px solid var(--line2);box-shadow:var(--shadow);box-sizing:border-box">
|
||
<div style="display:flex;flex-direction:column;gap:10px">
|
||
<div style="align-self:flex-start;display:flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;background:var(--warn-soft);color:var(--warn);font-size:12px;font-weight:600"><i style="font-size:16px">{{ setupBadgeIcon }}</i>{{ setupBadge }}</div>
|
||
<div style="font-family:Unbounded;font-size:24px;font-weight:600;letter-spacing:-0.01em;text-wrap:pretty">{{ setupTitle }}</div>
|
||
<div style="font-size:14px;line-height:1.5;color:var(--text2);text-wrap:pretty">{{ setupText }}</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:14px">
|
||
<sc-if value="{{ auth.setupAdmin }}">
|
||
<label style="display:flex;flex-direction:column;gap:6px">
|
||
<span style="font-size:13px;font-weight:500;color:var(--text2)">Новый логин</span>
|
||
<input value="{{ f.nlogin }}" onChange="{{ fh.nlogin }}" placeholder="не admin" style="height:48px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.nlogin }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)">
|
||
<sc-if value="{{ err.nlogin }}"><span style="font-size:12px;color:var(--danger)">{{ err.nlogin }}</span></sc-if>
|
||
</label>
|
||
</sc-if>
|
||
<label style="display:flex;flex-direction:column;gap:6px">
|
||
<span style="font-size:13px;font-weight:500;color:var(--text2)">Новый пароль</span>
|
||
<input type="password" value="{{ f.np }}" onChange="{{ fh.np }}" style="height:48px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.np }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-top:2px">
|
||
<div style="flex:1;display:grid;grid-template-columns:repeat(4,1fr);gap:4px">
|
||
<sc-for list="{{ strength.bars }}" as="b" hint-placeholder-count="4"><div style="height:4px;border-radius:2px;background:{{ b.bg }}"></div></sc-for>
|
||
</div>
|
||
<span style="font-size:12px;font-weight:500;color:{{ strength.color }};min-width:72px;text-align:right">{{ strength.label }}</span>
|
||
</div>
|
||
<sc-if value="{{ err.np }}"><span style="font-size:12px;color:var(--danger)">{{ err.np }}</span></sc-if>
|
||
</label>
|
||
<label style="display:flex;flex-direction:column;gap:6px">
|
||
<span style="font-size:13px;font-weight:500;color:var(--text2)">Подтверждение</span>
|
||
<input type="password" value="{{ f.np2 }}" onChange="{{ fh.np2 }}" style="height:48px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.np2 }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)">
|
||
<sc-if value="{{ err.np2 }}"><span style="font-size:12px;color:var(--danger)">{{ err.np2 }}</span></sc-if>
|
||
</label>
|
||
</div>
|
||
<button onClick="{{ onSetup }}" style="height:48px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:15px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)">Сохранить и продолжить</button>
|
||
<button onClick="{{ onLogout }}" style="height:20px;font-size:13px;color:var(--text3)" style-hover="color:var(--text)">Выйти</button>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ isApp }}" hint-placeholder-val="{{ true }}">
|
||
<div style="position:relative;z-index:1;height:100%;display:grid;grid-template-columns:var(--side-w) minmax(0,1fr);grid-template-rows:var(--rows);gap:var(--gap);padding:var(--pad);box-sizing:border-box">
|
||
|
||
<header style="grid-column:1/-1;grid-row:1;display:flex;align-items:center;gap:16px;padding:0 var(--top-px);border-bottom:var(--top-border);position:relative;z-index:30">
|
||
<div onClick="{{ nav.home }}" style="width:calc(var(--side-w) - 36px);display:flex;align-items:center;gap:10px;cursor:pointer;flex-shrink:0;padding-left:4px">
|
||
<div style="width:28px;height:28px;border-radius:50%;background:radial-gradient(circle at 35% 30%, #9CC6FF, #2F80FF 55%, #3A1FA8);box-shadow:0 0 18px rgba(47,128,255,.55)"></div>
|
||
<div style="font-family:Unbounded;font-weight:600;font-size:19px;letter-spacing:-0.02em">pipu</div>
|
||
</div>
|
||
<div style="flex:1;max-width:620px;position:relative">
|
||
<div style="display:flex;align-items:center;gap:10px;height:42px;padding:0 8px 0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ sBorder }}">
|
||
<i style="font-size:20px;color:var(--text3)">search</i>
|
||
<input ref="{{ searchRef }}" value="{{ q }}" onChange="{{ onSearchInput }}" onFocus="{{ onSearchFocus }}" onBlur="{{ onSearchBlur }}" onKeyDown="{{ onSearchKey }}" placeholder="Исполнители, альбомы, треки" style="flex:1;min-width:0;height:100%;background:none;border:0;outline:none;font-size:15px">
|
||
<div style="height:24px;padding:0 7px;border-radius:6px;border:1px solid var(--line2);display:flex;align-items:center;font-family:'JetBrains Mono';font-size:12px;color:var(--text3)">/</div>
|
||
</div>
|
||
<sc-if value="{{ showDrop }}">
|
||
<div style="position:absolute;top:calc(100% + 8px);left:0;right:0;z-index:60;display:flex;flex-direction:column;gap:4px;padding:8px;background:var(--elev);border:1px solid var(--line2);border-radius:16px;box-shadow:var(--shadow);max-height:70vh;overflow:auto;animation:pipuIn .14s ease-out">
|
||
<sc-if value="{{ sFixed }}">
|
||
<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;font-size:13px;color:var(--text2)"><i style="font-size:16px;color:var(--accent2)">keyboard</i>Исправили раскладку: «{{ sFixed }}»</div>
|
||
</sc-if>
|
||
<sc-if value="{{ sHas.artists }}">
|
||
<div style="padding:8px 10px 4px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Исполнители</div>
|
||
<sc-for list="{{ sArtists }}" as="it">
|
||
<div onMouseDown="{{ it.act }}" style="display:flex;align-items:center;gap:12px;padding:6px 10px;border-radius:10px;cursor:pointer;background:{{ it.bg }}" style-hover="background:var(--hover)">
|
||
<div style="width:40px;height:40px;border-radius:50%;background:{{ it.img }};display:flex;align-items:center;justify-content:center;font-family:Unbounded;font-weight:600;font-size:14px;color:#fff">{{ it.ini }}</div>
|
||
<div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-size:15px;font-weight:500">{{ it.title }}</span><span style="font-size:13px;color:var(--text2)">{{ it.sub }}</span></div>
|
||
</div>
|
||
</sc-for>
|
||
</sc-if>
|
||
<sc-if value="{{ sHas.albums }}">
|
||
<div style="padding:8px 10px 4px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Альбомы</div>
|
||
<sc-for list="{{ sAlbums }}" as="it">
|
||
<div onMouseDown="{{ it.act }}" style="display:flex;align-items:center;gap:12px;padding:6px 10px;border-radius:10px;cursor:pointer;background:{{ it.bg }}" style-hover="background:var(--hover)">
|
||
<div style="width:40px;height:40px;border-radius:8px;background:{{ it.img }}"></div>
|
||
<div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-size:15px;font-weight:500">{{ it.title }}</span><span style="font-size:13px;color:var(--text2)">{{ it.sub }}</span></div>
|
||
</div>
|
||
</sc-for>
|
||
</sc-if>
|
||
<sc-if value="{{ sHas.tracks }}">
|
||
<div style="padding:8px 10px 4px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Треки</div>
|
||
<sc-for list="{{ sTracks }}" as="it">
|
||
<div onMouseDown="{{ it.act }}" style="display:flex;align-items:center;gap:12px;padding:6px 10px;border-radius:10px;cursor:pointer;background:{{ it.bg }}" style-hover="background:var(--hover)">
|
||
<div style="width:40px;height:40px;border-radius:8px;background:{{ it.img }};display:flex;align-items:center;justify-content:center"><i style="font-size:20px;color:#fff;font-variation-settings:'FILL' 1;opacity:.9">play_arrow</i></div>
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-size:15px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ it.title }}</span><span style="font-size:13px;color:var(--text2)">{{ it.sub }}</span></div>
|
||
<span style="font-size:13px;color:var(--text3);font-variant-numeric:tabular-nums">{{ it.dur }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</sc-if>
|
||
<sc-if value="{{ sNone }}">
|
||
<div style="padding:20px 10px;text-align:center;font-size:14px;color:var(--text2)">Ничего не нашлось</div>
|
||
</sc-if>
|
||
<div onMouseDown="{{ openSearch }}" style="display:flex;align-items:center;justify-content:space-between;margin-top:4px;padding:10px;border-top:1px solid var(--line);font-size:14px;font-weight:500;color:var(--accent2);cursor:pointer">
|
||
<span>Все результаты по «{{ q }}»</span>
|
||
<span style="font-family:'JetBrains Mono';font-size:12px;color:var(--text3)">Enter ↵</span>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
<div style="flex:1"></div>
|
||
<div style="position:relative">
|
||
<button onClick="{{ toggleProfile }}" style="display:flex;align-items:center;gap:10px;height:42px;padding:0 6px 0 6px;border-radius:12px" style-hover="background:var(--hover)">
|
||
<div style="width:32px;height:32px;border-radius:50%;background:linear-gradient(140deg,#2F80FF,#7C5CFF);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff">{{ me.ini }}</div>
|
||
<i style="font-size:20px;color:var(--text3)">expand_more</i>
|
||
</button>
|
||
<sc-if value="{{ profileOpen }}">
|
||
<div style="position:absolute;right:0;top:calc(100% + 8px);width:260px;display:flex;flex-direction:column;padding:8px;background:var(--elev);border:1px solid var(--line2);border-radius:16px;box-shadow:var(--shadow);animation:pipuIn .14s ease-out;z-index:60">
|
||
<div style="display:flex;flex-direction:column;gap:2px;padding:10px 12px 12px;border-bottom:1px solid var(--line);margin-bottom:6px">
|
||
<span style="font-weight:600;font-size:15px">{{ me.name }}</span>
|
||
<span style="font-size:13px;color:var(--text2)">{{ me.login }} · {{ me.role }}</span>
|
||
</div>
|
||
<button onClick="{{ nav.settings }}" style="display:flex;align-items:center;gap:12px;height:40px;padding:0 12px;border-radius:10px;font-size:14px;text-align:left" style-hover="background:var(--hover)"><i style="font-size:20px;color:var(--text2)">settings</i>Профиль и настройки</button>
|
||
<button onClick="{{ toggleTheme }}" style="display:flex;align-items:center;gap:12px;height:40px;padding:0 12px;border-radius:10px;font-size:14px;text-align:left" style-hover="background:var(--hover)"><i style="font-size:20px;color:var(--text2)">{{ themeIcon }}</i>{{ themeLabel }}</button>
|
||
<button onClick="{{ onLogout }}" style="display:flex;align-items:center;gap:12px;height:40px;padding:0 12px;border-radius:10px;font-size:14px;text-align:left;color:var(--danger)" style-hover="background:var(--danger-soft)"><i style="font-size:20px">logout</i>Выйти</button>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
</header>
|
||
|
||
<nav style="grid-row:2;grid-column:1;display:flex;flex-direction:column;gap:2px;padding:12px;background:var(--panel-bg);backdrop-filter:var(--panel-blur);border-radius:var(--pr);border-right:var(--side-border);overflow:auto;box-sizing:border-box">
|
||
<sc-for list="{{ navTop }}" as="n" hint-placeholder-count="1">
|
||
<button onClick="{{ n.onClick }}" style="display:flex;align-items:center;gap:12px;height:42px;padding:0 12px;border-radius:12px;font-size:15px;font-weight:500;text-align:left;background:{{ n.bg }};color:{{ n.color }}" style-hover="background:var(--hover);color:var(--text)">
|
||
<i style="font-size:22px;color:{{ n.ic }};font-variation-settings:{{ n.fill }}">{{ n.icon }}</i><span style="flex:1">{{ n.label }}</span>
|
||
</button>
|
||
</sc-for>
|
||
<div style="height:12px"></div>
|
||
<div style="display:flex;align-items:center;gap:12px;height:32px;padding:0 12px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Коллекция</div>
|
||
<sc-for list="{{ navCol }}" as="n" hint-placeholder-count="3">
|
||
<button onClick="{{ n.onClick }}" style="display:flex;align-items:center;gap:12px;height:40px;padding:0 12px;border-radius:12px;font-size:15px;font-weight:500;text-align:left;background:{{ n.bg }};color:{{ n.color }}" style-hover="background:var(--hover);color:var(--text)">
|
||
<i style="font-size:21px;color:{{ n.ic }};font-variation-settings:{{ n.fill }}">{{ n.icon }}</i><span style="flex:1">{{ n.label }}</span><span style="font-size:12px;color:var(--text3);font-variant-numeric:tabular-nums">{{ n.count }}</span>
|
||
</button>
|
||
</sc-for>
|
||
<sc-if value="{{ showAdmin }}" hint-placeholder-val="{{ true }}">
|
||
<div style="height:12px"></div>
|
||
<div style="display:flex;align-items:center;gap:12px;height:32px;padding:0 12px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Админка</div>
|
||
<sc-for list="{{ navAdm }}" as="n" hint-placeholder-count="5">
|
||
<button onClick="{{ n.onClick }}" style="display:flex;align-items:center;gap:12px;height:40px;padding:0 12px;border-radius:12px;font-size:15px;font-weight:500;text-align:left;background:{{ n.bg }};color:{{ n.color }}" style-hover="background:var(--hover);color:var(--text)">
|
||
<i style="font-size:21px;color:{{ n.ic }};font-variation-settings:{{ n.fill }}">{{ n.icon }}</i><span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ n.label }}</span>
|
||
<sc-if value="{{ n.badge }}"><span style="min-width:22px;height:20px;padding:0 6px;border-radius:10px;background:var(--accent);color:#fff;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center;box-sizing:border-box">{{ n.badge }}</span></sc-if>
|
||
</button>
|
||
</sc-for>
|
||
</sc-if>
|
||
</nav>
|
||
|
||
<div style="grid-row:2;grid-column:2;position:relative;border-radius:var(--pr);background:var(--panel-bg);backdrop-filter:var(--panel-blur);overflow:hidden;min-width:0">
|
||
<div ref="{{ mainRef }}" style="position:absolute;inset:0;overflow-y:auto;overflow-x:hidden">
|
||
|
||
<sc-if value="{{ pg.home }}" hint-placeholder-val="{{ true }}">
|
||
<sc-if value="{{ homeCenter }}" hint-placeholder-val="{{ true }}">
|
||
<div style="min-height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:44px;padding:48px 32px var(--cpb);box-sizing:border-box">
|
||
<x-import component="WaveButton" from="./wave.js" variant="{{ waveVariant }}" playing="{{ waveIsPlaying }}" bpm="{{ cur.bpm }}" energy="{{ cur.energy }}" color="{{ waveC.c1 }}" color2="{{ waveC.c2 }}" color3="{{ waveC.c3 }}" theme="{{ theme }}" size="{{ 340 }}" onClick="{{ onWave }}" ariaLabel="Моя волна" hint-size="340px,340px">
|
||
<i style="font-size:52px;color:#fff;font-variation-settings:'FILL' 1">{{ waveIcon }}</i>
|
||
<div style="font-family:Unbounded;font-weight:600;font-size:26px;color:#fff;margin-top:10px;letter-spacing:-0.01em">Моя волна</div>
|
||
<div style="font-size:14px;color:rgba(255,255,255,.78);margin-top:6px">{{ ctxLabel }}</div>
|
||
</x-import>
|
||
<div style="display:flex;flex-direction:column;gap:22px;align-items:center;max-width:860px">
|
||
<div style="display:flex;flex-direction:column;gap:10px;align-items:center">
|
||
<div style="font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Занятие</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:8px;justify-content:center">
|
||
<sc-for list="{{ activities }}" as="c" hint-placeholder-count="7">
|
||
<button onClick="{{ c.onClick }}" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 16px 0 12px;border-radius:999px;font-size:14px;font-weight:500;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }};box-shadow:{{ c.shadow }}" style-hover="border-color:var(--accent)"><i style="font-size:19px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:10px;align-items:center">
|
||
<div style="font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Настроение</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:8px;justify-content:center">
|
||
<sc-for list="{{ moods }}" as="c" hint-placeholder-count="4">
|
||
<button onClick="{{ c.onClick }}" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 16px 0 12px;border-radius:999px;font-size:14px;font-weight:500;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }};box-shadow:{{ c.shadow }}" style-hover="border-color:var(--accent)"><i style="font-size:19px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<sc-if value="{{ waveOn }}">
|
||
<div style="display:flex;align-items:center;gap:10px;font-size:14px;color:var(--text2)">
|
||
<div style="width:6px;height:6px;border-radius:50%;background:var(--accent2);box-shadow:0 0 10px var(--accent)"></div>
|
||
<span>Сейчас в волне: <span style="color:var(--text);font-weight:500">{{ cur.title }}</span> — {{ cur.artist }}</span>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ homeSplit }}">
|
||
<div style="min-height:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));align-items:center;gap:40px 56px;padding:48px 56px var(--cpb);box-sizing:border-box;max-width:1280px;margin:0 auto">
|
||
<div style="display:flex;align-items:center;justify-content:center;min-height:480px">
|
||
<x-import component="WaveButton" from="./wave.js" variant="{{ waveVariant }}" playing="{{ waveIsPlaying }}" bpm="{{ cur.bpm }}" energy="{{ cur.energy }}" color="{{ waveC.c1 }}" color2="{{ waveC.c2 }}" color3="{{ waveC.c3 }}" theme="{{ theme }}" size="{{ 400 }}" onClick="{{ onWave }}" ariaLabel="Моя волна" hint-size="400px,400px">
|
||
<i style="font-size:64px;color:#fff;font-variation-settings:'FILL' 1">{{ waveIcon }}</i>
|
||
</x-import>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:28px;min-width:0">
|
||
<div style="display:flex;flex-direction:column;gap:12px">
|
||
<div style="font-family:Unbounded;font-size:52px;font-weight:600;letter-spacing:-0.03em;line-height:1.05">Моя волна</div>
|
||
<div style="font-size:17px;line-height:1.5;color:var(--text2);max-width:460px;text-wrap:pretty">Бесконечный поток под ваш вкус. Учится на лайках, пропусках и звучании треков — выберите, что вы делаете, и волна подстроится.</div>
|
||
</div>
|
||
<sc-if value="{{ waveOn }}">
|
||
<div style="display:flex;align-items:center;gap:14px;padding:12px;border-radius:16px;background:var(--surface2);border:1px solid var(--line)">
|
||
<div style="width:52px;height:52px;border-radius:10px;background:{{ cur.cover }}"></div>
|
||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px"><span style="font-weight:600;font-size:15px">{{ cur.title }}</span><span style="font-size:13px;color:var(--text2)">{{ cur.artist }} · {{ ctxLabel }}</span></div>
|
||
<button onClick="{{ next }}" style="width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover);color:var(--text)"><i style="font-variation-settings:'FILL' 1">skip_next</i></button>
|
||
</div>
|
||
</sc-if>
|
||
<div style="display:flex;flex-direction:column;gap:10px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between"><span style="font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Занятие</span>
|
||
<sc-if value="{{ hasCtx }}"><button onClick="{{ clearCtx }}" style="font-size:13px;color:var(--accent2)" style-hover="color:var(--text)">Сбросить — просто под меня</button></sc-if>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(128px,1fr));gap:8px">
|
||
<sc-for list="{{ activities }}" as="c" hint-placeholder-count="7">
|
||
<button onClick="{{ c.onClick }}" style="display:flex;flex-direction:column;align-items:flex-start;gap:14px;padding:14px;border-radius:14px;font-size:14px;font-weight:500;text-align:left;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }};box-shadow:{{ c.shadow }}" style-hover="border-color:var(--accent)"><i style="font-size:24px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:10px">
|
||
<span style="font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Настроение</span>
|
||
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px">
|
||
<sc-for list="{{ moods }}" as="c" hint-placeholder-count="4">
|
||
<button onClick="{{ c.onClick }}" style="display:flex;align-items:center;justify-content:center;gap:8px;height:44px;padding:0 10px;border-radius:12px;font-size:14px;font-weight:500;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }};box-shadow:{{ c.shadow }}" style-hover="border-color:var(--accent)"><i style="font-size:19px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ pg.search }}">
|
||
<div style="display:flex;flex-direction:column;gap:28px;padding:32px 36px var(--cpb);max-width:1200px">
|
||
<div style="display:flex;flex-direction:column;gap:6px">
|
||
<div style="font-size:14px;color:var(--text2)">Результаты поиска</div>
|
||
<div style="font-family:Unbounded;font-size:32px;font-weight:600;letter-spacing:-0.02em">«{{ sp.q }}»</div>
|
||
<sc-if value="{{ sp.fixed }}"><div style="font-size:14px;color:var(--text2)">Показаны результаты для «{{ sp.fixed }}» — запрос был набран в другой раскладке</div></sc-if>
|
||
</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||
<sc-for list="{{ sp.tabs }}" as="t" hint-placeholder-count="4">
|
||
<button onClick="{{ t.onClick }}" style="height:36px;padding:0 14px;border-radius:999px;font-size:14px;font-weight:500;background:{{ t.bg }};color:{{ t.color }};border:1px solid {{ t.border }}">{{ t.label }} <span style="opacity:.6">{{ t.count }}</span></button>
|
||
</sc-for>
|
||
</div>
|
||
<sc-if value="{{ sp.showArtists }}">
|
||
<div style="display:flex;flex-direction:column;gap:14px">
|
||
<div style="font-size:20px;font-weight:600">Исполнители</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px">
|
||
<sc-for list="{{ sp.artists }}" as="a">
|
||
<div onClick="{{ a.onClick }}" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px;border-radius:16px;cursor:pointer;text-align:center" style-hover="background:var(--hover)">
|
||
<div style="width:128px;height:128px;border-radius:50%;background:{{ a.img }};display:flex;align-items:center;justify-content:center;font-family:Unbounded;font-weight:600;font-size:34px;color:#fff">{{ a.ini }}</div>
|
||
<div style="display:flex;flex-direction:column;gap:3px"><span style="font-weight:600;font-size:15px">{{ a.name }}</span><span style="font-size:13px;color:var(--text2)">{{ a.sub }}</span></div>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ sp.showAlbums }}">
|
||
<div style="display:flex;flex-direction:column;gap:14px">
|
||
<div style="font-size:20px;font-weight:600">Альбомы</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:16px">
|
||
<sc-for list="{{ sp.albums }}" as="a">
|
||
<div onClick="{{ a.onClick }}" style="display:flex;flex-direction:column;gap:10px;padding:10px;border-radius:16px;cursor:pointer" style-hover="background:var(--hover)">
|
||
<div style="aspect-ratio:1;border-radius:12px;background:{{ a.img }}"></div>
|
||
<div style="display:flex;flex-direction:column;gap:3px;min-width:0"><span style="font-weight:600;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ a.title }}</span><span style="font-size:13px;color:var(--text2)">{{ a.sub }}</span></div>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ sp.showTracks }}">
|
||
<div style="display:flex;flex-direction:column;gap:8px">
|
||
<div style="font-size:20px;font-weight:600;margin-bottom:6px">Треки</div>
|
||
<sc-for list="{{ sp.tracks }}" as="r">
|
||
<div onClick="{{ r.onPlay }}" style="display:grid;grid-template-columns:32px 44px minmax(0,1.4fr) minmax(0,1fr) 40px 52px;align-items:center;gap:14px;padding:6px 12px;border-radius:12px;cursor:pointer" style-hover="background:var(--hover)">
|
||
<div style="display:flex;justify-content:center;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><sc-if value="{{ r.notCur }}">{{ r.num }}</sc-if></div>
|
||
<div style="width:44px;height:44px;border-radius:8px;background:{{ r.cover }}"></div>
|
||
<div style="min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span onClick="{{ r.onArtist }}" style="font-size:13px;color:var(--text2);align-self:flex-start" style-hover="color:var(--text);text-decoration:underline">{{ r.artist }}</span></div>
|
||
<span onClick="{{ r.onAlbum }}" style="font-size:14px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" style-hover="color:var(--text)">{{ r.album }}</span>
|
||
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
|
||
<span style="text-align:right;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums">{{ r.dur }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ sp.none }}">
|
||
<div style="padding:64px 0;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--text2)"><i style="font-size:40px;color:var(--text3)">search_off</i><span style="font-size:16px">Ничего не нашлось. Попробуйте другое написание.</span></div>
|
||
</sc-if>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ pg.artist }}">
|
||
<div style="display:flex;flex-direction:column">
|
||
<div style="position:relative;padding:56px 36px 32px;background:{{ ap.banner }};display:flex;align-items:flex-end;gap:28px;flex-wrap:wrap">
|
||
<div style="width:200px;height:200px;border-radius:50%;background:{{ ap.img }};box-shadow:0 20px 60px rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font-family:Unbounded;font-weight:600;font-size:56px;color:#fff;flex-shrink:0">{{ ap.ini }}</div>
|
||
<div style="display:flex;flex-direction:column;gap:12px;min-width:0;padding-bottom:6px">
|
||
<span style="font-size:13px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text2)">Исполнитель · {{ ap.genre }}</span>
|
||
<div style="font-family:Unbounded;font-size:60px;font-weight:600;letter-spacing:-0.03em;line-height:1">{{ ap.name }}</div>
|
||
<span style="font-size:14px;color:var(--text2)">{{ ap.meta }}</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:32px;padding:8px 36px var(--cpb)">
|
||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||
<button onClick="{{ ap.onWave }}" style="display:flex;align-items:center;gap:10px;height:48px;padding:0 22px 0 16px;border-radius:999px;background:var(--accent);color:#fff;font-weight:600;font-size:15px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)"><i style="font-size:22px">graphic_eq</i>Волна по исполнителю</button>
|
||
<button onClick="{{ ap.onPlay }}" style="display:flex;align-items:center;gap:8px;height:48px;padding:0 20px 0 14px;border-radius:999px;background:var(--surface2);border:1px solid var(--line2);font-weight:600;font-size:15px" style-hover="background:var(--surface3)"><i style="font-variation-settings:'FILL' 1">play_arrow</i>Слушать</button>
|
||
<button onClick="{{ ap.onShuffle }}" aria-label="Перемешать" style="width:48px;height:48px;border-radius:50%;background:var(--surface2);border:1px solid var(--line2);display:flex;align-items:center;justify-content:center" style-hover="background:var(--surface3)"><i>shuffle</i></button>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:4px">
|
||
<div style="font-size:20px;font-weight:600;margin-bottom:10px">Популярные треки</div>
|
||
<sc-for list="{{ ap.tracks }}" as="r">
|
||
<div onClick="{{ r.onPlay }}" style="display:grid;grid-template-columns:32px 44px minmax(0,1.4fr) minmax(0,1fr) 40px 52px;align-items:center;gap:14px;padding:6px 12px;border-radius:12px;cursor:pointer" style-hover="background:var(--hover)">
|
||
<div style="display:flex;justify-content:center;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><sc-if value="{{ r.notCur }}">{{ r.num }}</sc-if></div>
|
||
<div style="width:44px;height:44px;border-radius:8px;background:{{ r.cover }}"></div>
|
||
<span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span>
|
||
<span onClick="{{ r.onAlbum }}" style="font-size:14px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" style-hover="color:var(--text)">{{ r.album }}</span>
|
||
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
|
||
<span style="text-align:right;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums">{{ r.dur }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:14px">
|
||
<div style="font-size:20px;font-weight:600">Альбомы</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px">
|
||
<sc-for list="{{ ap.albums }}" as="a">
|
||
<div onClick="{{ a.onClick }}" style="display:flex;flex-direction:column;gap:10px;padding:10px;border-radius:16px;cursor:pointer" style-hover="background:var(--hover)">
|
||
<div style="aspect-ratio:1;border-radius:12px;background:{{ a.img }};box-shadow:0 12px 32px rgba(0,0,0,.3)"></div>
|
||
<div style="display:flex;flex-direction:column;gap:3px;min-width:0"><span style="font-weight:600;font-size:15px">{{ a.title }}</span><span style="font-size:13px;color:var(--text2)">{{ a.sub }}</span></div>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ pg.album }}">
|
||
<div style="display:flex;flex-direction:column">
|
||
<div style="padding:48px 36px 28px;background:{{ alp.tint }};display:flex;align-items:flex-end;gap:28px;flex-wrap:wrap">
|
||
<div style="width:232px;height:232px;border-radius:16px;background:{{ alp.cover }};box-shadow:0 24px 64px rgba(0,0,0,.5);flex-shrink:0"></div>
|
||
<div style="display:flex;flex-direction:column;gap:12px;min-width:0;padding-bottom:4px">
|
||
<span style="font-size:13px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text2)">Альбом</span>
|
||
<div style="font-family:Unbounded;font-size:48px;font-weight:600;letter-spacing:-0.03em;line-height:1.05;text-wrap:balance">{{ alp.title }}</div>
|
||
<div style="display:flex;align-items:center;gap:8px;font-size:14px;color:var(--text2);flex-wrap:wrap">
|
||
<div style="width:24px;height:24px;border-radius:50%;background:{{ alp.artistImg }}"></div>
|
||
<span onClick="{{ alp.onArtist }}" style="color:var(--text);font-weight:600;cursor:pointer" style-hover="text-decoration:underline">{{ alp.artist }}</span>
|
||
<span>· {{ alp.year }} · {{ alp.meta }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:20px;padding:8px 36px var(--cpb)">
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<button onClick="{{ alp.onPlay }}" aria-label="Слушать" style="width:56px;height:56px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)"><i style="font-size:32px;font-variation-settings:'FILL' 1">{{ alp.playIcon }}</i></button>
|
||
<button onClick="{{ alp.onShuffle }}" aria-label="Перемешать" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover);color:var(--text)"><i>shuffle</i></button>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column">
|
||
<div style="display:grid;grid-template-columns:32px minmax(0,1fr) 40px 52px;gap:14px;padding:0 12px 10px;border-bottom:1px solid var(--line);font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)"><span style="text-align:center">#</span><span>Название</span><span></span><span style="text-align:right"><i style="font-size:16px">schedule</i></span></div>
|
||
<div style="height:8px"></div>
|
||
<sc-for list="{{ alp.tracks }}" as="r">
|
||
<div onClick="{{ r.onPlay }}" style="display:grid;grid-template-columns:32px minmax(0,1fr) 40px 52px;align-items:center;gap:14px;height:52px;padding:0 12px;border-radius:12px;cursor:pointer" style-hover="background:var(--hover)">
|
||
<div style="display:flex;justify-content:center;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><sc-if value="{{ r.notCur }}">{{ r.num }}</sc-if></div>
|
||
<div style="min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span style="font-size:13px;color:var(--text2)">{{ r.artist }}</span></div>
|
||
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
|
||
<span style="text-align:right;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums">{{ r.dur }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ pg.collection }}">
|
||
<div style="display:flex;flex-direction:column;gap:24px;padding:32px 36px var(--cpb);max-width:1200px">
|
||
<div style="display:flex;align-items:flex-end;gap:24px;flex-wrap:wrap">
|
||
<div style="width:120px;height:120px;border-radius:20px;background:{{ col.tile }};display:flex;align-items:center;justify-content:center;box-shadow:0 16px 40px rgba(0,0,0,.3)"><i style="font-size:52px;color:#fff;font-variation-settings:'FILL' 1">{{ col.icon }}</i></div>
|
||
<div style="display:flex;flex-direction:column;gap:8px;flex:1;min-width:240px">
|
||
<span style="font-size:13px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text2)">Коллекция</span>
|
||
<div style="font-family:Unbounded;font-size:40px;font-weight:600;letter-spacing:-0.02em;line-height:1">{{ col.title }}</div>
|
||
<span style="font-size:14px;color:var(--text2);text-wrap:pretty">{{ col.sub }}</span>
|
||
</div>
|
||
<sc-if value="{{ col.canPlay }}">
|
||
<div style="display:flex;gap:10px">
|
||
<button onClick="{{ col.onPlay }}" style="display:flex;align-items:center;gap:8px;height:44px;padding:0 20px 0 14px;border-radius:999px;background:var(--accent);color:#fff;font-weight:600;font-size:15px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)"><i style="font-variation-settings:'FILL' 1">play_arrow</i>Слушать всё</button>
|
||
<button onClick="{{ col.onShuffle }}" style="display:flex;align-items:center;gap:8px;height:44px;padding:0 18px 0 14px;border-radius:999px;background:var(--surface2);border:1px solid var(--line2);font-weight:600;font-size:15px" style-hover="background:var(--surface3)"><i>shuffle</i>Перемешать</button>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
<div style="display:flex;gap:6px;border-bottom:1px solid var(--line)">
|
||
<sc-for list="{{ col.tabs }}" as="t" hint-placeholder-count="3">
|
||
<button onClick="{{ t.onClick }}" style="height:42px;padding:0 14px;font-size:15px;font-weight:500;color:{{ t.color }};border-bottom:2px solid {{ t.border }};margin-bottom:-1px">{{ t.label }}</button>
|
||
</sc-for>
|
||
</div>
|
||
<sc-for list="{{ col.groups }}" as="g">
|
||
<div style="display:flex;flex-direction:column;gap:2px">
|
||
<sc-if value="{{ g.day }}"><div style="font-size:14px;font-weight:600;color:var(--text2);padding:8px 12px">{{ g.day }}</div></sc-if>
|
||
<sc-for list="{{ g.rows }}" as="r">
|
||
<div onClick="{{ r.onPlay }}" style="display:grid;grid-template-columns:44px 44px minmax(0,1.4fr) minmax(0,1fr) auto 52px;align-items:center;gap:14px;padding:6px 12px;border-radius:12px;cursor:pointer" style-hover="background:var(--hover)">
|
||
<div style="display:flex;justify-content:center;font-size:13px;color:var(--text3);font-variant-numeric:tabular-nums"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><sc-if value="{{ r.notCur }}">{{ r.num }}</sc-if></div>
|
||
<div style="width:44px;height:44px;border-radius:8px;background:{{ r.cover }};opacity:{{ r.op }}"></div>
|
||
<div style="min-width:0;display:flex;flex-direction:column;gap:2px;opacity:{{ r.op }}"><span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span onClick="{{ r.onArtist }}" style="font-size:13px;color:var(--text2);align-self:flex-start" style-hover="color:var(--text);text-decoration:underline">{{ r.artist }}</span></div>
|
||
<span onClick="{{ r.onAlbum }}" style="font-size:14px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:{{ r.op }}" style-hover="color:var(--text)">{{ r.album }}</span>
|
||
<sc-if value="{{ col.isDis }}">
|
||
<button onClick="{{ r.onUndo }}" style="display:flex;align-items:center;gap:6px;height:34px;padding:0 12px;border-radius:999px;border:1px solid var(--line2);font-size:13px;font-weight:500;color:var(--text2)" style-hover="color:var(--text);border-color:var(--accent)"><i style="font-size:18px">undo</i>Убрать дизлайк</button>
|
||
</sc-if>
|
||
<sc-if value="{{ col.notDis }}">
|
||
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
|
||
</sc-if>
|
||
<span style="text-align:right;font-size:14px;color:var(--text3);font-variant-numeric:tabular-nums">{{ r.dur }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</sc-for>
|
||
<sc-if value="{{ col.empty }}">
|
||
<div style="padding:56px 0;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--text2)"><i style="font-size:40px;color:var(--text3)">{{ col.icon }}</i><span style="font-size:16px">{{ col.emptyText }}</span></div>
|
||
</sc-if>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ pg.settings }}">
|
||
<div style="display:flex;flex-direction:column;gap:28px;padding:32px 36px var(--cpb);max-width:760px">
|
||
<div style="font-family:Unbounded;font-size:32px;font-weight:600;letter-spacing:-0.02em">Профиль и настройки</div>
|
||
<div style="display:flex;align-items:center;gap:16px;padding:20px;border-radius:20px;background:var(--surface2);border:1px solid var(--line)">
|
||
<div style="width:56px;height:56px;border-radius:50%;background:linear-gradient(140deg,#2F80FF,#7C5CFF);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:#fff">{{ me.ini }}</div>
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:3px"><span style="font-weight:600;font-size:17px">{{ me.name }}</span><span style="font-size:14px;color:var(--text2)">Логин: {{ me.login }}</span></div>
|
||
<span style="height:28px;padding:0 12px;border-radius:999px;background:var(--accent-soft);color:var(--accent2);font-size:13px;font-weight:600;display:flex;align-items:center">{{ me.role }}</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:14px">
|
||
<div style="font-size:18px;font-weight:600">Смена пароля</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Текущий пароль</span><input type="password" value="{{ f.cur }}" onChange="{{ fh.cur }}" style="height:44px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.cur }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)"></label>
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Новый пароль</span><input type="password" value="{{ f.sp }}" onChange="{{ fh.sp }}" style="height:44px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.sp }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)"></label>
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Ещё раз</span><input type="password" value="{{ f.sp2 }}" onChange="{{ fh.sp2 }}" style="height:44px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ errB.sp2 }};outline:none;font-size:15px" style-focus="border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)"></label>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
|
||
<button onClick="{{ onChangePw }}" style="height:42px;padding:0 18px;border-radius:12px;background:var(--surface3);border:1px solid var(--line2);font-weight:600;font-size:14px" style-hover="border-color:var(--accent)">Сменить пароль</button>
|
||
<sc-if value="{{ err.sp }}"><span style="font-size:13px;color:var(--danger)">{{ err.sp }}</span></sc-if>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;border-radius:20px;border:1px solid var(--line);overflow:hidden">
|
||
<div style="display:flex;align-items:center;gap:16px;padding:16px 20px;border-bottom:1px solid var(--line)">
|
||
<i style="color:var(--text2)">translate</i>
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Язык интерфейса</span><span style="font-size:13px;color:var(--text2)">Словари ru / en, можно добавить другие</span></div>
|
||
<div style="display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--surface2)">
|
||
<sc-for list="{{ langs }}" as="l" hint-placeholder-count="2"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 14px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.full }}</button></sc-for>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:16px;padding:16px 20px;border-bottom:1px solid var(--line)">
|
||
<i style="color:var(--text2)">contrast</i>
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Тема</span></div>
|
||
<div style="display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--surface2)">
|
||
<sc-for list="{{ themes }}" as="l" hint-placeholder-count="2"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 14px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button></sc-for>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:16px;padding:16px 20px;border-bottom:1px solid var(--line)">
|
||
<i style="color:var(--text2)">blur_linear</i>
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Кроссфейд</span><span style="font-size:13px;color:var(--text2)">Плавный переход между треками</span></div>
|
||
<input type="range" min="0" max="6" step="1" value="{{ crossfade }}" onChange="{{ onCrossfade }}" style="width:160px;accent-color:var(--accent)">
|
||
<span style="width:36px;text-align:right;font-size:14px;color:var(--text2);font-variant-numeric:tabular-nums">{{ crossfade }} с</span>
|
||
</div>
|
||
<div onClick="{{ toggleNormalize }}" style="display:flex;align-items:center;gap:16px;padding:16px 20px;cursor:pointer">
|
||
<i style="color:var(--text2)">equalizer</i>
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Выравнивание громкости</span><span style="font-size:13px;color:var(--text2)">По EBU R128, без перепадов между треками</span></div>
|
||
<div style="width:44px;height:26px;border-radius:13px;background:{{ normT.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:3px;left:{{ normT.x }};width:20px;height:20px;border-radius:50%;background:#fff;transition:left .2s;box-shadow:0 1px 3px rgba(0,0,0,.3)"></div></div>
|
||
</div>
|
||
</div>
|
||
<button onClick="{{ onLogout }}" style="align-self:flex-start;display:flex;align-items:center;gap:8px;height:42px;padding:0 16px;border-radius:12px;color:var(--danger);font-weight:600;font-size:14px;border:1px solid var(--danger-soft)" style-hover="background:var(--danger-soft)"><i style="font-size:20px">logout</i>Выйти из аккаунта</button>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ pg.users }}">
|
||
<div onClick="{{ closeMenus }}" style="display:flex;flex-direction:column;gap:20px;padding:32px 36px var(--cpb);max-width:1100px;min-height:100%;box-sizing:border-box">
|
||
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
|
||
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Пользователи</div><div style="font-size:14px;color:var(--text2)">{{ uu.hint }}</div></div>
|
||
<button onClick="{{ uu.onCreate }}" style="display:flex;align-items:center;gap:8px;height:44px;padding:0 18px 0 14px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)"><i style="font-size:20px">person_add</i>Создать пользователя</button>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||
<div style="display:flex;align-items:center;gap:8px;height:40px;padding:0 12px;border-radius:12px;background:var(--surface2);border:1px solid var(--line);flex:1;min-width:220px;max-width:340px"><i style="font-size:19px;color:var(--text3)">search</i><input value="{{ uu.q }}" onChange="{{ uu.onQ }}" placeholder="Имя или логин" style="flex:1;min-width:0;background:none;border:0;outline:none;font-size:14px"></div>
|
||
<div style="display:flex;gap:2px;padding:3px;border-radius:12px;background:var(--surface2);border:1px solid var(--line)">
|
||
<sc-for list="{{ uu.tabs }}" as="t" hint-placeholder-count="4"><button onClick="{{ t.onClick }}" style="height:32px;padding:0 12px;border-radius:9px;font-size:13px;font-weight:500;background:{{ t.bg }};color:{{ t.color }}">{{ t.label }} <span style="opacity:.55">{{ t.count }}</span></button></sc-for>
|
||
</div>
|
||
</div>
|
||
<div style="overflow-x:auto;border:1px solid var(--line);border-radius:18px;background:var(--surface-a)"><div style="display:flex;flex-direction:column;min-width:660px">
|
||
<div style="display:grid;grid-template-columns:minmax(200px,2fr) minmax(110px,1fr) minmax(120px,1fr) minmax(90px,.8fr) 44px;gap:16px;align-items:center;padding:12px 20px;border-bottom:1px solid var(--line);font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)"><span>Пользователь</span><span>Роль</span><span>Статус</span><span>Был в сети</span><span></span></div>
|
||
<sc-for list="{{ uu.rows }}" as="u">
|
||
<div style="display:grid;grid-template-columns:minmax(200px,2fr) minmax(110px,1fr) minmax(120px,1fr) minmax(90px,.8fr) 44px;gap:16px;align-items:center;padding:12px 20px;border-bottom:1px solid var(--line);position:relative">
|
||
<div style="display:flex;align-items:center;gap:12px;min-width:0;opacity:{{ u.op }}">
|
||
<div style="width:38px;height:38px;border-radius:50%;background:var(--surface3);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:var(--text2);flex-shrink:0">{{ u.ini }}</div>
|
||
<div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;font-size:15px">{{ u.name }}</span><span style="font-size:13px;color:var(--text3);font-family:'JetBrains Mono'">{{ u.login }}</span></div>
|
||
</div>
|
||
<span style="justify-self:start;height:26px;padding:0 10px;border-radius:999px;background:{{ u.roleBg }};color:{{ u.roleColor }};font-size:12px;font-weight:600;display:flex;align-items:center">{{ u.roleLabel }}</span>
|
||
<span style="display:flex;align-items:center;gap:8px;font-size:14px;color:var(--text2)"><span style="width:7px;height:7px;border-radius:50%;background:{{ u.stColor }}"></span>{{ u.stLabel }}</span>
|
||
<span style="font-size:14px;color:var(--text2)">{{ u.last }}</span>
|
||
<sc-if value="{{ u.canManage }}">
|
||
<button onClick="{{ u.onMenu }}" aria-label="Действия" style="width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover);color:var(--text)"><i>more_horiz</i></button>
|
||
</sc-if>
|
||
<sc-if value="{{ u.menuOpen }}">
|
||
<div style="position:absolute;right:20px;top:calc(100% - 6px);z-index:30;width:240px;display:flex;flex-direction:column;padding:6px;background:var(--elev);border:1px solid var(--line2);border-radius:14px;box-shadow:var(--shadow);animation:pipuIn .14s ease-out">
|
||
<sc-for list="{{ u.acts }}" as="a"><button onClick="{{ a.onClick }}" style="display:flex;align-items:center;gap:10px;height:38px;padding:0 10px;border-radius:9px;font-size:14px;text-align:left;color:{{ a.color }}" style-hover="background:var(--hover)"><i style="font-size:19px">{{ a.icon }}</i>{{ a.label }}</button></sc-for>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
</sc-for>
|
||
</div></div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ pg.sources }}">
|
||
<div style="display:flex;flex-direction:column;gap:24px;padding:32px 36px var(--cpb);max-width:1180px">
|
||
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
|
||
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Хранилища</div><div style="font-size:14px;color:var(--text2)">Откуда берётся музыка и как из путей извлекаются данные о треках</div></div>
|
||
<button style="display:flex;align-items:center;gap:8px;height:44px;padding:0 18px 0 14px;border-radius:12px;background:var(--surface2);border:1px solid var(--line2);font-weight:600;font-size:14px" style-hover="border-color:var(--accent)"><i style="font-size:20px">add</i>Добавить хранилище</button>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px">
|
||
<sc-for list="{{ src.list }}" as="x" hint-placeholder-count="3">
|
||
<div onClick="{{ x.onClick }}" style="display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:16px;background:{{ x.bg }};border:1px solid {{ x.border }};cursor:pointer">
|
||
<div style="display:flex;align-items:center;gap:10px"><i style="color:var(--text2)">hard_drive</i><span style="flex:1;font-weight:600;font-size:15px">{{ x.name }}</span><span style="display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:{{ x.stColor }}"><span style="width:7px;height:7px;border-radius:50%;background:{{ x.stColor }}"></span>{{ x.stLabel }}</span></div>
|
||
<span style="font-family:'JetBrains Mono';font-size:12px;color:var(--text2);word-break:break-all">{{ x.addr }}</span>
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--text3)"><span>{{ x.typeLabel }}</span><span>·</span><span>{{ x.tracks }}</span><sc-if value="{{ x.accepts }}"><span style="height:20px;padding:0 8px;border-radius:6px;background:var(--accent-soft);color:var(--accent2);font-weight:600;display:flex;align-items:center">принимает загрузки</span></sc-if></div>
|
||
<sc-if value="{{ x.err }}"><span style="font-size:12px;line-height:1.4;color:var(--danger)">{{ x.err }}</span></sc-if>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
|
||
<div style="display:flex;flex-direction:column;gap:20px;padding:24px;border-radius:20px;border:1px solid var(--line);background:var(--surface-a)">
|
||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||
<input value="{{ src.name }}" onChange="{{ src.onName }}" style="flex:1;min-width:200px;font-size:20px;font-weight:600;background:transparent;border:1px solid transparent;border-radius:8px;padding:4px 8px;margin-left:-9px;outline:none" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent)">
|
||
<div style="display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--surface2)"><sc-for list="{{ src.types }}" as="l" hint-placeholder-count="2"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 12px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button></sc-for></div>
|
||
</div>
|
||
<sc-if value="{{ src.isNfs }}">
|
||
<div style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) minmax(0,1.4fr);gap:12px">
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Адрес сервера</span><input value="{{ src.server }}" onChange="{{ src.onServer }}" style="height:42px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-family:'JetBrains Mono';font-size:13px" style-focus="border-color:var(--accent)"></label>
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Путь экспорта</span><input value="{{ src.exp }}" onChange="{{ src.onExp }}" style="height:42px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-family:'JetBrains Mono';font-size:13px" style-focus="border-color:var(--accent)"></label>
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Опции монтирования</span><input value="{{ src.opts }}" onChange="{{ src.onOpts }}" style="height:42px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-family:'JetBrains Mono';font-size:13px" style-focus="border-color:var(--accent)"></label>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ src.isLocal }}">
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Подпапка в /srv/pipu/library-host</span><input value="{{ src.path }}" onChange="{{ src.onPath }}" style="height:42px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-family:'JetBrains Mono';font-size:13px" style-focus="border-color:var(--accent)"></label>
|
||
</sc-if>
|
||
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
|
||
<button onClick="{{ src.onTest }}" style="display:flex;align-items:center;gap:8px;height:40px;padding:0 16px 0 12px;border-radius:10px;background:var(--surface3);border:1px solid var(--line2);font-weight:600;font-size:14px" style-hover="border-color:var(--accent)"><i style="font-size:19px">lan</i>{{ src.testLabel }}</button>
|
||
<div onClick="{{ src.onAcc }}" style="display:flex;align-items:center;gap:10px;cursor:pointer;font-size:14px"><div style="width:44px;height:26px;border-radius:13px;background:{{ src.acc.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:3px;left:{{ src.acc.x }};width:20px;height:20px;border-radius:50%;background:#fff;transition:left .2s"></div></div>Принимает новые треки (монтируется на запись)</div>
|
||
</div>
|
||
<sc-if value="{{ src.testRes }}">
|
||
<div style="display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:12px;background:{{ src.testRes.b }};color:{{ src.testRes.c }};font-size:14px;font-weight:500"><i style="font-size:20px">{{ src.testRes.icon }}</i>{{ src.testRes.text }}</div>
|
||
</sc-if>
|
||
|
||
<div style="height:1px;background:var(--line)"></div>
|
||
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap">
|
||
<div style="display:flex;flex-direction:column;gap:4px"><span style="font-size:17px;font-weight:600">Шаблоны путей</span><span style="font-size:13px;color:var(--text2)">Регулярные выражения RE2 с именованными группами. Срабатывает первое совпавшее — порядок меняется перетаскиванием.</span></div>
|
||
<div style="display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--surface2)"><sc-for list="{{ src.tags }}" as="l" hint-placeholder-count="2"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 12px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button></sc-for></div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:8px">
|
||
<sc-for list="{{ src.patterns }}" as="p" hint-placeholder-count="4">
|
||
<div draggable="true" onDragStart="{{ p.onDragStart }}" onDragOver="{{ p.onDragOver }}" onDrop="{{ p.onDrop }}" onDragEnd="{{ p.onDragEnd }}" style="display:flex;align-items:flex-start;gap:10px">
|
||
<div style="display:flex;align-items:center;gap:4px;height:42px;color:var(--text3);cursor:grab"><i style="font-size:20px">drag_indicator</i><span style="font-family:'JetBrains Mono';font-size:12px;width:20px">#{{ p.n }}</span></div>
|
||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px">
|
||
<div style="position:relative;border-radius:10px;background:var(--surface2);border:1px solid {{ p.border }}">
|
||
<div aria-hidden="true" style="padding:10px 12px;font-family:'JetBrains Mono';font-size:13px;line-height:20px;white-space:pre-wrap;word-break:break-all;min-height:20px"><sc-for list="{{ p.tokens }}" as="k"><span style="color:{{ k.c }};font-weight:{{ k.w }}">{{ k.t }}</span></sc-for></div>
|
||
<textarea value="{{ p.text }}" onChange="{{ p.onChange }}" spellcheck="false" style="position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;padding:10px 12px;margin:0;border:0;outline:none;resize:none;overflow:hidden;background:transparent;color:transparent;caret-color:var(--text);font-family:'JetBrains Mono';font-size:13px;line-height:20px;white-space:pre-wrap;word-break:break-all"></textarea>
|
||
</div>
|
||
<sc-if value="{{ p.err }}"><span style="font-size:12px;color:var(--danger);font-family:'JetBrains Mono'">{{ p.err }}</span></sc-if>
|
||
</div>
|
||
<button onClick="{{ p.onDel }}" aria-label="Удалить" style="width:42px;height:42px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="background:var(--danger-soft);color:var(--danger)"><i style="font-size:20px">delete</i></button>
|
||
</div>
|
||
</sc-for>
|
||
<button onClick="{{ src.onAddPattern }}" style="align-self:flex-start;display:flex;align-items:center;gap:6px;height:36px;padding:0 12px;margin-left:56px;border-radius:10px;font-size:14px;font-weight:500;color:var(--accent2)" style-hover="background:var(--accent-soft)"><i style="font-size:19px">add</i>Добавить выражение</button>
|
||
</div>
|
||
|
||
<div style="display:flex;flex-direction:column;gap:10px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px"><span style="font-size:15px;font-weight:600">Предпросмотр на реальных файлах</span><span style="font-size:13px;color:var(--text2)">{{ src.prevSummary }}</span></div>
|
||
<div style="display:flex;flex-direction:column;border:1px solid var(--line);border-radius:14px;overflow:hidden">
|
||
<sc-for list="{{ src.prev }}" as="r">
|
||
<div style="display:flex;flex-direction:column;gap:8px;padding:12px 14px;border-bottom:1px solid var(--line)">
|
||
<div style="display:flex;align-items:flex-start;gap:12px">
|
||
<div style="flex:1;min-width:0;font-family:'JetBrains Mono';font-size:13px;line-height:20px;word-break:break-all"><sc-for list="{{ r.segs }}" as="g"><span style="color:{{ g.c }}">{{ g.t }}</span></sc-for></div>
|
||
<span style="flex-shrink:0;height:22px;padding:0 8px;border-radius:6px;background:{{ r.bb }};color:{{ r.bc }};font-size:12px;font-weight:600;display:flex;align-items:center">{{ r.badge }}</span>
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||
<sc-for list="{{ r.fields }}" as="fl"><span style="display:flex;align-items:center;gap:6px;height:24px;padding:0 8px;border-radius:6px;background:var(--surface2);font-size:12px"><span style="color:{{ fl.c }};font-weight:600">{{ fl.k }}</span><span style="color:var(--text)">{{ fl.v }}</span></span></sc-for>
|
||
</div>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="height:1px;background:var(--line)"></div>
|
||
<div style="display:flex;flex-direction:column;gap:10px">
|
||
<div style="display:flex;flex-direction:column;gap:4px"><span style="font-size:17px;font-weight:600">Шаблон раскладки новых треков</span><span style="font-size:13px;color:var(--text2)">По нему одобренные файлы записываются в хранилище — так же, как уже разложена музыка</span></div>
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||
<input value="{{ src.tpl }}" onChange="{{ src.onTpl }}" spellcheck="false" style="flex:1;min-width:280px;height:42px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-family:'JetBrains Mono';font-size:13px" style-focus="border-color:var(--accent)">
|
||
<button onClick="{{ src.onAutoTpl }}" style="display:flex;align-items:center;gap:6px;height:42px;padding:0 14px 0 10px;border-radius:10px;background:var(--surface3);border:1px solid var(--line2);font-weight:600;font-size:14px" style-hover="border-color:var(--accent)"><i style="font-size:19px">auto_fix_high</i>Подобрать автоматически</button>
|
||
<button onClick="{{ src.onCheckTpl }}" style="display:flex;align-items:center;gap:6px;height:42px;padding:0 14px 0 10px;border-radius:10px;background:var(--surface3);border:1px solid var(--line2);font-weight:600;font-size:14px" style-hover="border-color:var(--accent)"><i style="font-size:19px">fact_check</i>Проверить</button>
|
||
</div>
|
||
<sc-if value="{{ src.checked }}">
|
||
<div style="display:flex;flex-direction:column;gap:8px;padding:14px;border-radius:14px;background:{{ src.chkBg }}">
|
||
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:{{ src.chkColor }}">{{ src.chkSummary }}</span><span style="font-size:13px;color:var(--text2)">{{ src.chkNote }}</span></div>
|
||
<sc-for list="{{ src.chk }}" as="c">
|
||
<div style="display:grid;grid-template-columns:20px minmax(0,1fr);gap:8px;align-items:start;font-family:'JetBrains Mono';font-size:12px;line-height:18px">
|
||
<i style="font-size:18px;color:{{ c.c }}">{{ c.icon }}</i>
|
||
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;word-break:break-all"><span style="color:var(--text2)">{{ c.real }}</span><span style="color:{{ c.c }}">→ {{ c.gen }}</span></div>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
<button onClick="{{ src.onSave }}" style="align-self:flex-start;height:44px;padding:0 20px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)">Сохранить и пересканировать</button>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ pg.library }}">
|
||
<div style="display:flex;flex-direction:column;gap:24px;padding:32px 36px var(--cpb);max-width:1180px">
|
||
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Библиотека</div><div style="font-size:14px;color:var(--text2)">Состояние каталога, сканирования и аудиоанализа</div></div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
|
||
<sc-for list="{{ lib.stats }}" as="x" hint-placeholder-count="4">
|
||
<div style="display:flex;flex-direction:column;gap:6px;padding:18px;border-radius:16px;background:var(--surface-a);border:1px solid var(--line)"><span style="font-size:13px;color:var(--text2)">{{ x.l }}</span><span style="font-family:Unbounded;font-size:28px;font-weight:600;letter-spacing:-0.02em">{{ x.v }}</span><span style="font-size:12px;color:var(--text3)">{{ x.n }}</span></div>
|
||
</sc-for>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:12px">
|
||
<div style="display:flex;flex-direction:column;gap:12px;padding:20px;border-radius:18px;background:var(--surface-a);border:1px solid var(--line)">
|
||
<div style="display:flex;align-items:center;gap:10px"><i style="color:var(--accent2)">folder_open</i><span style="flex:1;font-weight:600">{{ lib.scanLabel }}</span><span style="font-family:Unbounded;font-weight:600">{{ lib.scanPct }}</span></div>
|
||
<div style="height:8px;border-radius:4px;background:var(--line2);overflow:hidden"><div style="height:100%;width:{{ lib.scanW }};border-radius:4px;background:linear-gradient(90deg,var(--accent),#35D3FF)"></div></div>
|
||
<div style="display:flex;justify-content:space-between;gap:12px;font-size:13px;color:var(--text2)"><span>{{ lib.scanFiles }}</span><span>{{ lib.scanEta }}</span></div>
|
||
<button onClick="{{ lib.onRescan }}" style="align-self:flex-start;display:flex;align-items:center;gap:6px;height:36px;padding:0 12px 0 8px;border-radius:10px;font-size:13px;font-weight:600;border:1px solid var(--line2)" style-hover="border-color:var(--accent)"><i style="font-size:18px">refresh</i>Пересканировать</button>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:12px;padding:20px;border-radius:18px;background:var(--surface-a);border:1px solid var(--line)">
|
||
<div style="display:flex;align-items:center;gap:10px"><i style="color:var(--violet)">graphic_eq</i><span style="flex:1;font-weight:600">Аудиоанализ звучания</span><span style="font-family:Unbounded;font-weight:600">{{ lib.anaPct }}</span></div>
|
||
<div style="height:8px;border-radius:4px;background:var(--line2);overflow:hidden"><div style="height:100%;width:{{ lib.anaW }};border-radius:4px;background:linear-gradient(90deg,#7C5CFF,#B39CFF)"></div></div>
|
||
<div style="display:flex;justify-content:space-between;gap:12px;font-size:13px;color:var(--text2)"><span>{{ lib.anaFiles }}</span><span>{{ lib.anaEta }}</span></div>
|
||
<span style="font-size:13px;color:var(--text3)">Темп, энергия, настроение, жанры — нужны волне даже для треков, которые никто не слушал</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:12px">
|
||
<span style="font-size:17px;font-weight:600">Качество «Моей волны» · 7 дней</span>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px">
|
||
<sc-for list="{{ lib.metrics }}" as="x" hint-placeholder-count="5">
|
||
<div style="display:flex;flex-direction:column;gap:4px;padding:16px;border-radius:14px;border:1px solid var(--line)"><span style="font-size:13px;color:var(--text2)">{{ x.l }}</span><span style="font-size:24px;font-weight:600">{{ x.v }}</span><span style="font-size:12px;color:var(--ok)">{{ x.n }}</span></div>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:12px">
|
||
<span style="font-size:17px;font-weight:600">Ошибки</span>
|
||
<div style="display:flex;flex-direction:column;border:1px solid var(--line);border-radius:14px;overflow:hidden">
|
||
<sc-for list="{{ lib.errors }}" as="x">
|
||
<div style="display:grid;grid-template-columns:20px minmax(0,1fr) 110px;gap:12px;align-items:start;padding:12px 14px;border-bottom:1px solid var(--line)">
|
||
<i style="font-size:19px;color:var(--danger)">error</i>
|
||
<div style="display:flex;flex-direction:column;gap:3px;min-width:0"><span style="font-family:'JetBrains Mono';font-size:12px;color:var(--text);word-break:break-all">{{ x.p }}</span><span style="font-size:13px;color:var(--text2)">{{ x.e }}</span></div>
|
||
<span style="font-size:12px;color:var(--text3);text-align:right">{{ x.w }}</span>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ pg.upload }}">
|
||
<div style="display:flex;flex-direction:column;gap:24px;padding:32px 36px var(--cpb);max-width:1000px">
|
||
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Предложить треки</div><div style="font-size:14px;color:var(--text2);text-wrap:pretty">Файлы попадут в очередь на добавление. Дубли отсекаются до загрузки и не занимают место.</div></div>
|
||
<div style="display:flex;flex-direction:column;gap:10px;padding:18px 20px;border-radius:16px;background:var(--surface-a);border:1px solid var(--line)">
|
||
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:12px"><span style="font-weight:600">Очередь на добавление</span><span style="font-size:14px;color:var(--text2)"><span style="font-family:Unbounded;font-weight:600;color:var(--text);font-size:18px">{{ up.usedStr }}</span> из {{ up.limStr }} ГБ · свободно {{ up.freeStr }} ГБ</span></div>
|
||
<div style="height:10px;border-radius:5px;background:var(--line2);overflow:hidden"><div style="height:100%;width:{{ up.pct }};border-radius:5px;background:{{ up.barC }};transition:width .3s"></div></div>
|
||
</div>
|
||
<div onClick="{{ up.onPick }}" onDragOver="{{ up.onDragOver }}" onDragLeave="{{ up.onDragLeave }}" onDrop="{{ up.onDrop }}" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:44px 24px;border-radius:20px;border:2px dashed {{ up.dzBorder }};background:{{ up.dzBg }};cursor:pointer;text-align:center;transition:background .15s" style-hover="border-color:var(--accent)">
|
||
<div style="width:56px;height:56px;border-radius:50%;background:var(--accent-soft);display:flex;align-items:center;justify-content:center"><i style="font-size:28px;color:var(--accent2)">upload</i></div>
|
||
<span style="font-size:17px;font-weight:600">Перетащите файлы или папки альбомов</span>
|
||
<span style="font-size:13px;color:var(--text2)">flac, mp3, m4a, ogg, opus, wav, aiff, wv · до 2 ГБ на файл · cover.jpg привяжется к альбому</span>
|
||
</div>
|
||
<sc-if value="{{ up.hasCur }}">
|
||
<div style="display:flex;flex-direction:column;gap:10px">
|
||
<span style="font-size:17px;font-weight:600">Текущая загрузка</span>
|
||
<div style="display:flex;flex-direction:column;border:1px solid var(--line);border-radius:14px;overflow:hidden">
|
||
<sc-for list="{{ up.cur }}" as="u">
|
||
<div style="display:flex;flex-direction:column;gap:8px;padding:12px 14px;border-bottom:1px solid var(--line);opacity:{{ u.op }}">
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<i style="font-size:20px;color:var(--text3)">audio_file</i>
|
||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ u.name }}</span><span style="font-size:12px;color:var(--text3)">{{ u.folder }} · {{ u.size }}</span></div>
|
||
<span style="height:24px;padding:0 9px;border-radius:999px;background:{{ u.stBg }};color:{{ u.stColor }};font-size:12px;font-weight:600;display:flex;align-items:center">{{ u.stLabel }}</span>
|
||
<sc-if value="{{ u.canW }}"><button onClick="{{ u.onW }}" style="font-size:13px;color:var(--text2)" style-hover="color:var(--danger)">Отозвать</button></sc-if>
|
||
</div>
|
||
<sc-if value="{{ u.isUp }}"><div style="height:4px;border-radius:2px;background:var(--line2);overflow:hidden;margin-left:32px"><div style="height:100%;width:{{ u.w }};background:var(--accent);transition:width .25s"></div></div></sc-if>
|
||
<sc-if value="{{ u.hasNote }}"><span style="margin-left:32px;font-size:12px;color:var(--text2)">{{ u.note }}</span></sc-if>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
<div style="display:flex;flex-direction:column;gap:10px">
|
||
<span style="font-size:17px;font-weight:600">Мои предложения</span>
|
||
<div style="display:flex;flex-direction:column;border:1px solid var(--line);border-radius:14px;overflow:hidden">
|
||
<sc-for list="{{ up.props }}" as="p">
|
||
<div style="display:flex;align-items:center;gap:14px;padding:14px;border-bottom:1px solid var(--line)">
|
||
<i style="color:var(--text3)">album</i>
|
||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px"><span style="font-size:15px;font-weight:500">{{ p.name }}</span><span style="font-size:13px;color:var(--text3)">{{ p.meta }}</span><sc-if value="{{ p.hasReason }}"><span style="font-size:13px;color:var(--text2)">{{ p.reason }}</span></sc-if></div>
|
||
<span style="height:24px;padding:0 9px;border-radius:999px;background:{{ p.stBg }};color:{{ p.stColor }};font-size:12px;font-weight:600;display:flex;align-items:center;white-space:nowrap">{{ p.stLabel }}</span>
|
||
<sc-if value="{{ p.canW }}"><button onClick="{{ p.onW }}" style="font-size:13px;color:var(--text2)" style-hover="color:var(--danger)">Отозвать</button></sc-if>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ pg.queue }}">
|
||
<div style="display:flex;flex-direction:column;gap:20px;padding:32px 36px var(--cpb);max-width:1280px">
|
||
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
|
||
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Очередь на добавление</div><div style="font-size:14px;color:var(--text2)">{{ qq.summary }}</div></div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap"><sc-for list="{{ qq.filters }}" as="t" hint-placeholder-count="3"><button onClick="{{ t.onClick }}" style="height:34px;padding:0 14px;border-radius:999px;font-size:13px;font-weight:500;background:{{ t.bg }};color:{{ t.color }};border:1px solid {{ t.border }}">{{ t.label }} <span style="opacity:.6">{{ t.count }}</span></button></sc-for></div>
|
||
</div>
|
||
<sc-if value="{{ qq.hasSel }}">
|
||
<div style="position:sticky;top:12px;z-index:10;display:flex;align-items:center;gap:12px;padding:10px 12px 10px 18px;border-radius:14px;background:var(--elev);border:1px solid var(--accent);box-shadow:var(--shadow);animation:pipuIn .14s ease-out">
|
||
<span style="flex:1;font-weight:600;font-size:14px">{{ qq.selLabel }}</span>
|
||
<button onClick="{{ qq.onClearSel }}" style="height:36px;padding:0 12px;border-radius:10px;font-size:13px;color:var(--text2)" style-hover="background:var(--hover)">Снять выделение</button>
|
||
<button onClick="{{ qq.onBulkReject }}" style="display:flex;align-items:center;gap:6px;height:36px;padding:0 14px 0 10px;border-radius:10px;font-size:13px;font-weight:600;color:var(--danger);border:1px solid var(--danger-soft)" style-hover="background:var(--danger-soft)"><i style="font-size:18px">close</i>Отклонить</button>
|
||
<button onClick="{{ qq.onBulkApprove }}" style="display:flex;align-items:center;gap:6px;height:36px;padding:0 14px 0 10px;border-radius:10px;font-size:13px;font-weight:600;background:var(--ok);color:#04110B" style-hover="filter:brightness(1.08)"><i style="font-size:18px">check</i>Одобрить</button>
|
||
</div>
|
||
</sc-if>
|
||
<sc-if value="{{ qq.empty }}">
|
||
<div style="padding:64px 0;display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--text2)"><i style="font-size:44px;color:var(--ok)">task_alt</i><span style="font-size:16px">Всё рассмотрено — новых предложений нет</span></div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ qq.grouped }}" hint-placeholder-val="{{ true }}">
|
||
<sc-for list="{{ qq.groups }}" as="g">
|
||
<div style="display:flex;flex-direction:column;border-radius:20px;border:1px solid var(--line);background:var(--surface-a);overflow:hidden">
|
||
<div style="display:flex;align-items:center;gap:18px;padding:18px 20px;flex-wrap:wrap;border-bottom:1px solid var(--line)">
|
||
<div onClick="{{ g.onSelAll }}" style="width:20px;height:20px;border-radius:6px;border:1.5px solid {{ g.selBorder }};background:{{ g.selBg }};display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0"><i style="font-size:16px;color:#fff">{{ g.selIcon }}</i></div>
|
||
<div style="position:relative;width:76px;height:76px;border-radius:12px;background:{{ g.cover }};flex-shrink:0;cursor:pointer" title="Заменить обложку"><div style="position:absolute;right:4px;bottom:4px;width:24px;height:24px;border-radius:7px;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center"><i style="font-size:15px;color:#fff">edit</i></div></div>
|
||
<div style="flex:1;min-width:260px;display:flex;flex-direction:column;gap:2px">
|
||
<div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap">
|
||
<input value="{{ g.artist }}" onChange="{{ g.onArtist }}" style="width:{{ g.aw }};max-width:100%;font-size:19px;font-weight:600;background:transparent;border:1px solid transparent;border-radius:8px;padding:2px 8px;margin-left:-9px;outline:none" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent);background:var(--surface2)">
|
||
<span style="color:var(--text3)">—</span>
|
||
<input value="{{ g.album }}" onChange="{{ g.onAlbum }}" style="width:{{ g.alw }};max-width:100%;font-size:19px;font-weight:600;background:transparent;border:1px solid transparent;border-radius:8px;padding:2px 8px;outline:none" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent);background:var(--surface2)">
|
||
<input value="{{ g.year }}" onChange="{{ g.onYear }}" style="width:70px;font-size:16px;color:var(--text2);background:transparent;border:1px solid transparent;border-radius:8px;padding:2px 8px;outline:none" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent);background:var(--surface2)">
|
||
</div>
|
||
<span style="font-size:13px;color:var(--text2)">{{ g.count }} · {{ g.sizeStr }} · {{ g.quality }} · от {{ g.by }}, {{ g.when }}</span>
|
||
<span style="font-size:13px;color:var(--accent2)">{{ g.note }}</span>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button onClick="{{ g.onReject }}" style="height:38px;padding:0 14px;border-radius:10px;font-size:13px;font-weight:600;color:var(--danger);border:1px solid var(--danger-soft)" style-hover="background:var(--danger-soft)">Отклонить</button>
|
||
<button onClick="{{ g.onApprove }}" style="display:flex;align-items:center;gap:6px;height:38px;padding:0 14px 0 10px;border-radius:10px;font-size:13px;font-weight:600;background:var(--ok);color:#04110B" style-hover="filter:brightness(1.08)"><i style="font-size:18px">done_all</i>Одобрить альбом</button>
|
||
</div>
|
||
</div>
|
||
<sc-for list="{{ g.tracks }}" as="t">
|
||
<div style="display:grid;grid-template-columns:20px 34px 26px minmax(0,1fr) 64px 76px;gap:12px;align-items:center;padding:8px 20px;border-bottom:1px solid var(--line)" style-hover="background:var(--hover)">
|
||
<div onClick="{{ t.onSel }}" style="width:20px;height:20px;border-radius:6px;border:1.5px solid {{ t.selBorder }};background:{{ t.selBg }};display:flex;align-items:center;justify-content:center;cursor:pointer"><i style="font-size:16px;color:#fff">{{ t.selIcon }}</i></div>
|
||
<button onClick="{{ t.onPlay }}" aria-label="Прослушать" style="width:32px;height:32px;border-radius:50%;background:{{ t.playBg }};color:{{ t.playC }};display:flex;align-items:center;justify-content:center"><i style="font-size:20px;font-variation-settings:'FILL' 1">{{ t.playIcon }}</i></button>
|
||
<span style="font-size:13px;color:var(--text3);font-variant-numeric:tabular-nums">{{ t.n }}</span>
|
||
<div style="display:flex;flex-direction:column;gap:3px;min-width:0">
|
||
<input value="{{ t.title }}" onChange="{{ t.onTitle }}" style="font-size:14px;font-weight:500;background:transparent;border:1px solid transparent;border-radius:6px;padding:2px 6px;margin-left:-7px;outline:none;min-width:0" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent);background:var(--surface2)">
|
||
<sc-if value="{{ t.hasFlag }}"><span style="align-self:flex-start;display:flex;align-items:center;gap:4px;height:20px;padding:0 7px;border-radius:6px;background:{{ t.flagBg }};color:{{ t.flagColor }};font-size:11px;font-weight:600"><i style="font-size:13px">{{ t.flagIcon }}</i>{{ t.flagLabel }}</span></sc-if>
|
||
<sc-if value="{{ t.hasNote }}"><span style="font-size:12px;color:var(--text2)">{{ t.note }}</span></sc-if>
|
||
<div style="display:flex;align-items:flex-start;gap:6px;font-family:'JetBrains Mono';font-size:12px;line-height:17px;overflow-wrap:anywhere"><i style="font-size:15px;color:var(--text3);margin-top:1px">subdirectory_arrow_right</i><span><sc-for list="{{ t.segs }}" as="sg"><span style="color:{{ sg.c }}">{{ sg.t }}</span></sc-for></span></div>
|
||
</div>
|
||
<span style="font-size:12px;color:var(--text3);text-align:right">{{ t.size }}</span>
|
||
<div style="display:flex;gap:4px;justify-content:flex-end">
|
||
<button onClick="{{ t.onReject }}" aria-label="Отклонить" style="width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="background:var(--danger-soft);color:var(--danger)"><i style="font-size:19px">close</i></button>
|
||
<button onClick="{{ t.onApprove }}" aria-label="Одобрить" style="width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="background:var(--ok-soft);color:var(--ok)"><i style="font-size:19px">check</i></button>
|
||
</div>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
</sc-for>
|
||
<div style="display:flex;align-items:center;gap:16px;font-size:12px;color:var(--text3)"><span style="display:flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:var(--text2)"></span>папка уже есть</span><span style="display:flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:var(--accent2)"></span>будет создана</span></div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ qq.split }}">
|
||
<div style="display:grid;grid-template-columns:300px minmax(0,1fr);gap:16px;align-items:start">
|
||
<div style="display:flex;flex-direction:column;gap:6px;position:sticky;top:12px">
|
||
<sc-for list="{{ qq.groups }}" as="g">
|
||
<div onClick="{{ g.onPick }}" style="display:flex;align-items:center;gap:12px;padding:10px;border-radius:14px;background:{{ g.bg }};border:1px solid {{ g.border }};cursor:pointer" style-hover="background:var(--hover)">
|
||
<div style="width:52px;height:52px;border-radius:10px;background:{{ g.cover }};flex-shrink:0"></div>
|
||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ g.album }}</span><span style="font-size:13px;color:var(--text2)">{{ g.artist }}</span><span style="font-size:12px;color:var(--text3)">{{ g.count }} · {{ g.by }}</span></div>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
<sc-if value="{{ qq.hasCur }}">
|
||
<div style="display:flex;flex-direction:column;gap:20px;padding:24px;border-radius:20px;border:1px solid var(--line);background:var(--surface-a)">
|
||
<div style="display:flex;gap:20px;flex-wrap:wrap">
|
||
<div style="position:relative;width:160px;height:160px;border-radius:16px;background:{{ qq.cur.cover }};flex-shrink:0"><button style="position:absolute;left:8px;bottom:8px;display:flex;align-items:center;gap:4px;height:28px;padding:0 10px 0 6px;border-radius:8px;background:rgba(0,0,0,.6);color:#fff;font-size:12px;font-weight:600"><i style="font-size:16px">image</i>Заменить</button></div>
|
||
<div style="flex:1;min-width:260px;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;align-content:start">
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:12px;color:var(--text2)">Исполнитель альбома</span><input value="{{ qq.cur.artist }}" onChange="{{ qq.cur.onArtist }}" style="height:40px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:14px" style-focus="border-color:var(--accent)"></label>
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:12px;color:var(--text2)">Альбом</span><input value="{{ qq.cur.album }}" onChange="{{ qq.cur.onAlbum }}" style="height:40px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:14px" style-focus="border-color:var(--accent)"></label>
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:12px;color:var(--text2)">Год</span><input value="{{ qq.cur.year }}" onChange="{{ qq.cur.onYear }}" style="height:40px;padding:0 12px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:14px" style-focus="border-color:var(--accent)"></label>
|
||
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-size:12px;color:var(--text2)">Качество</span><span style="height:40px;display:flex;align-items:center;font-size:14px">{{ qq.cur.quality }}</span></div>
|
||
<span style="grid-column:1/-1;font-size:13px;color:var(--text2)">{{ qq.cur.count }} · {{ qq.cur.sizeStr }} · от {{ qq.cur.by }}, {{ qq.cur.when }} · <span style="color:var(--accent2)">{{ qq.cur.note }}</span></span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:8px;padding:14px 16px;border-radius:14px;background:var(--surface2)">
|
||
<span style="font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Куда ляжет в хранилище</span>
|
||
<sc-for list="{{ qq.cur.tree }}" as="n">
|
||
<div style="display:flex;align-items:center;gap:8px;padding-left:{{ n.pad }};font-family:'JetBrains Mono';font-size:13px;color:{{ n.c }}"><i style="font-size:17px">{{ n.icon }}</i><span>{{ n.t }}</span><span style="font-family:Onest;font-size:12px;color:var(--text3)">{{ n.note }}</span></div>
|
||
</sc-for>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column">
|
||
<sc-for list="{{ qq.cur.tracks }}" as="t">
|
||
<div style="display:grid;grid-template-columns:20px 34px 26px minmax(0,1fr) 64px 76px;gap:12px;align-items:center;padding:8px 4px;border-bottom:1px solid var(--line)">
|
||
<div onClick="{{ t.onSel }}" style="width:20px;height:20px;border-radius:6px;border:1.5px solid {{ t.selBorder }};background:{{ t.selBg }};display:flex;align-items:center;justify-content:center;cursor:pointer"><i style="font-size:16px;color:#fff">{{ t.selIcon }}</i></div>
|
||
<button onClick="{{ t.onPlay }}" aria-label="Прослушать" style="width:32px;height:32px;border-radius:50%;background:{{ t.playBg }};color:{{ t.playC }};display:flex;align-items:center;justify-content:center"><i style="font-size:20px;font-variation-settings:'FILL' 1">{{ t.playIcon }}</i></button>
|
||
<span style="font-size:13px;color:var(--text3)">{{ t.n }}</span>
|
||
<div style="display:flex;flex-direction:column;gap:3px;min-width:0">
|
||
<input value="{{ t.title }}" onChange="{{ t.onTitle }}" style="font-size:14px;font-weight:500;background:transparent;border:1px solid transparent;border-radius:6px;padding:2px 6px;margin-left:-7px;outline:none;min-width:0" style-hover="border-color:var(--line2)" style-focus="border-color:var(--accent);background:var(--surface2)">
|
||
<sc-if value="{{ t.hasFlag }}"><span style="align-self:flex-start;display:flex;align-items:center;gap:4px;height:20px;padding:0 7px;border-radius:6px;background:{{ t.flagBg }};color:{{ t.flagColor }};font-size:11px;font-weight:600"><i style="font-size:13px">{{ t.flagIcon }}</i>{{ t.flagLabel }}</span></sc-if>
|
||
<sc-if value="{{ t.hasNote }}"><span style="font-size:12px;color:var(--text2)">{{ t.note }}</span></sc-if>
|
||
</div>
|
||
<span style="font-size:12px;color:var(--text3);text-align:right">{{ t.size }}</span>
|
||
<div style="display:flex;gap:4px;justify-content:flex-end">
|
||
<button onClick="{{ t.onReject }}" aria-label="Отклонить" style="width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="background:var(--danger-soft);color:var(--danger)"><i style="font-size:19px">close</i></button>
|
||
<button onClick="{{ t.onApprove }}" aria-label="Одобрить" style="width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--text3)" style-hover="background:var(--ok-soft);color:var(--ok)"><i style="font-size:19px">check</i></button>
|
||
</div>
|
||
</div>
|
||
</sc-for>
|
||
</div>
|
||
<div style="display:flex;justify-content:flex-end;gap:8px">
|
||
<button onClick="{{ qq.cur.onReject }}" style="height:42px;padding:0 16px;border-radius:12px;font-size:14px;font-weight:600;color:var(--danger);border:1px solid var(--danger-soft)" style-hover="background:var(--danger-soft)">Отклонить альбом</button>
|
||
<button onClick="{{ qq.cur.onApprove }}" style="display:flex;align-items:center;gap:6px;height:42px;padding:0 18px 0 14px;border-radius:12px;font-size:14px;font-weight:600;background:var(--ok);color:#04110B" style-hover="filter:brightness(1.08)"><i style="font-size:19px">done_all</i>Одобрить и разложить</button>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ pg.aset }}">
|
||
<div style="display:flex;flex-direction:column;gap:24px;padding:32px 36px var(--cpb);max-width:760px">
|
||
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Настройки</div><div style="font-size:14px;color:var(--text2)">Очередь на добавление и запись в хранилище</div></div>
|
||
<div style="display:flex;flex-direction:column;border-radius:20px;border:1px solid var(--line);background:var(--surface-a);overflow:hidden">
|
||
<div style="display:flex;align-items:center;gap:16px;padding:18px 20px;border-bottom:1px solid var(--line)">
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Лимит очереди</span><span style="font-size:13px;color:var(--text2)">Общий объём файлов на рассмотрении</span></div>
|
||
<div style="display:flex;align-items:center;gap:8px"><input type="number" min="1" value="{{ as.limit }}" onChange="{{ as.onLimit }}" style="width:80px;height:40px;padding:0 10px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px;text-align:right" style-focus="border-color:var(--accent)"><span style="color:var(--text2)">ГБ</span></div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:12px;padding:18px 20px;border-bottom:1px solid var(--line)">
|
||
<div onClick="{{ as.onPerUser }}" style="display:flex;align-items:center;gap:16px;cursor:pointer">
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Лимит на модератора</span><span style="font-size:13px;color:var(--text2)">Чтобы один человек не занял всю очередь</span></div>
|
||
<div style="width:44px;height:26px;border-radius:13px;background:{{ as.perUser.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:3px;left:{{ as.perUser.x }};width:20px;height:20px;border-radius:50%;background:#fff;transition:left .2s"></div></div>
|
||
</div>
|
||
<sc-if value="{{ as.showPer }}"><div style="display:flex;align-items:center;gap:8px"><input type="number" min="1" value="{{ as.perUserLimit }}" onChange="{{ as.onPerUserLimit }}" style="width:80px;height:40px;padding:0 10px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px;text-align:right"><span style="color:var(--text2)">ГБ на человека</span></div></sc-if>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:16px;padding:18px 20px;border-bottom:1px solid var(--line)">
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Максимальный размер файла</span></div>
|
||
<div style="display:flex;align-items:center;gap:8px"><input type="number" min="1" value="{{ as.maxFile }}" onChange="{{ as.onMaxFile }}" style="width:80px;height:40px;padding:0 10px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px;text-align:right" style-focus="border-color:var(--accent)"><span style="color:var(--text2)">ГБ</span></div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:16px;padding:18px 20px;border-bottom:1px solid var(--line)">
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Хранилище для новых треков</span><span style="font-size:13px;color:var(--text2)">При одобрении можно выбрать другое</span></div>
|
||
<select value="{{ as.defSrc }}" onChange="{{ as.onDefSrc }}" style="height:40px;padding:0 10px;border-radius:10px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:14px">
|
||
<sc-for list="{{ as.srcOpts }}" as="o"><option value="{{ o.v }}">{{ o.l }}</option></sc-for>
|
||
</select>
|
||
</div>
|
||
<div onClick="{{ as.onUpgrade }}" style="display:flex;align-items:center;gap:16px;padding:18px 20px;cursor:pointer">
|
||
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500">Принимать замену на лучшее качество</span><span style="font-size:13px;color:var(--text2)">Тот же трек в lossless пойдёт на рассмотрение, а не отсеется как дубль</span></div>
|
||
<div style="width:44px;height:26px;border-radius:13px;background:{{ as.upgrade.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:3px;left:{{ as.upgrade.x }};width:20px;height:20px;border-radius:50%;background:#fff;transition:left .2s"></div></div>
|
||
</div>
|
||
</div>
|
||
<button onClick="{{ as.onSave }}" style="align-self:flex-start;height:44px;padding:0 20px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;box-shadow:var(--glow)" style-hover="filter:brightness(1.1)">Сохранить</button>
|
||
</div>
|
||
</sc-if>
|
||
|
||
</div>
|
||
|
||
<sc-if value="{{ isIsland }}">
|
||
<div style="position:absolute;left:50%;bottom:16px;transform:translateX(-50%);width:min(860px, calc(100% - 32px));background:var(--glass);backdrop-filter:blur(24px) saturate(1.4);border:1px solid var(--line2);border-radius:22px;box-shadow:var(--shadow);overflow:hidden;z-index:20">
|
||
<div style="display:flex;align-items:center;gap:14px;padding:10px 14px 12px 10px">
|
||
<div onClick="{{ cur.onAlbum }}" style="width:52px;height:52px;border-radius:12px;background:{{ cur.cover }};cursor:pointer;flex-shrink:0"></div>
|
||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||
<span style="font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ cur.title }}</span>
|
||
<div style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text2);min-width:0"><span onClick="{{ cur.onArtist }}" style="cursor:pointer;white-space:nowrap" style-hover="color:var(--text)">{{ cur.artist }}</span><sc-if value="{{ waveOn }}"><span style="color:var(--accent2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">· {{ srcLabel }}</span></sc-if></div>
|
||
</div>
|
||
<button onClick="{{ prev }}" aria-label="Назад" style="width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:26px;font-variation-settings:'FILL' 1">skip_previous</i></button>
|
||
<button onClick="{{ togglePlay }}" aria-label="Пауза" style="width:48px;height:48px;border-radius:50%;background:var(--text);color:var(--bg);display:flex;align-items:center;justify-content:center" style-hover="transform:scale(1.05)"><i style="font-size:30px;font-variation-settings:'FILL' 1">{{ playIcon }}</i></button>
|
||
<button onClick="{{ next }}" aria-label="Вперёд" style="width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:26px;font-variation-settings:'FILL' 1">skip_next</i></button>
|
||
<span style="width:92px;text-align:center;font-size:13px;color:var(--text2);font-variant-numeric:tabular-nums">{{ posStr }} / {{ durStr }}</span>
|
||
<button onClick="{{ cur.onLike }}" aria-label="Лайк" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:22px;color:{{ cur.likeColor }};font-variation-settings:{{ cur.likeFill }}">favorite</i></button>
|
||
<button onClick="{{ cur.onDislike }}" aria-label="Дизлайк" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:22px;color:{{ cur.disColor }};font-variation-settings:{{ cur.disFill }}">thumb_down</i></button>
|
||
<button onClick="{{ toggleQueue }}" aria-label="Очередь" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:{{ queueColor }}" style-hover="background:var(--hover)"><i style="font-size:22px">queue_music</i></button>
|
||
<button onClick="{{ toggleMute }}" aria-label="Громкость" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover)"><i style="font-size:22px">{{ volIcon }}</i></button>
|
||
</div>
|
||
<div onClick="{{ onSeek }}" style="position:absolute;left:0;right:0;bottom:0;height:10px;cursor:pointer;display:flex;align-items:flex-end">
|
||
<div style="width:100%;height:3px;background:var(--line2)"><div style="height:100%;width:{{ pct }};background:linear-gradient(90deg,var(--accent),#35D3FF);box-shadow:0 0 8px var(--accent)"></div></div>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
</div>
|
||
|
||
<sc-if value="{{ isBar }}" hint-placeholder-val="{{ true }}">
|
||
<footer style="grid-row:3;grid-column:1/-1;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr) minmax(0,1fr);align-items:center;gap:24px;padding:0 20px;background:var(--player-bg);backdrop-filter:blur(28px) saturate(1.2);border-top:var(--top-border);border-radius:var(--pr)">
|
||
<div style="display:flex;align-items:center;gap:14px;min-width:0">
|
||
<div onClick="{{ cur.onAlbum }}" style="width:56px;height:56px;border-radius:10px;background:{{ cur.cover }};cursor:pointer;flex-shrink:0"></div>
|
||
<div style="min-width:0;display:flex;flex-direction:column;gap:3px">
|
||
<span style="font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ cur.title }}</span>
|
||
<span onClick="{{ cur.onArtist }}" style="font-size:13px;color:var(--text2);cursor:pointer;align-self:flex-start" style-hover="color:var(--text);text-decoration:underline">{{ cur.artist }}</span>
|
||
<div style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:{{ srcColor }};white-space:nowrap;overflow:hidden"><i style="font-size:15px">{{ srcIcon }}</i><span style="overflow:hidden;text-overflow:ellipsis">{{ srcLabel }}</span></div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0">
|
||
<div style="display:flex;align-items:center;gap:14px">
|
||
<button onClick="{{ cur.onDislike }}" aria-label="Дизлайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ cur.disColor }};font-variation-settings:{{ cur.disFill }}">thumb_down</i></button>
|
||
<button onClick="{{ prev }}" aria-label="Назад" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:28px;font-variation-settings:'FILL' 1">skip_previous</i></button>
|
||
<button onClick="{{ togglePlay }}" aria-label="Пауза" style="width:44px;height:44px;border-radius:50%;background:var(--text);color:var(--bg);display:flex;align-items:center;justify-content:center" style-hover="transform:scale(1.06)"><i style="font-size:30px;font-variation-settings:'FILL' 1">{{ playIcon }}</i></button>
|
||
<button onClick="{{ next }}" aria-label="Вперёд" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:28px;font-variation-settings:'FILL' 1">skip_next</i></button>
|
||
<button onClick="{{ cur.onLike }}" aria-label="Лайк" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center" style-hover="background:var(--hover)"><i style="font-size:20px;color:{{ cur.likeColor }};font-variation-settings:{{ cur.likeFill }}">favorite</i></button>
|
||
</div>
|
||
<div style="width:100%;max-width:560px;display:flex;align-items:center;gap:10px;font-size:12px;color:var(--text3);font-variant-numeric:tabular-nums">
|
||
<span style="width:36px;text-align:right">{{ posStr }}</span>
|
||
<div onClick="{{ onSeek }}" style="flex:1;height:14px;display:flex;align-items:center;cursor:pointer">
|
||
<div style="position:relative;width:100%;height:4px;border-radius:2px;background:var(--line2)">
|
||
<div style="height:100%;width:{{ pct }};border-radius:2px;background:linear-gradient(90deg,var(--accent),#35D3FF)"></div>
|
||
<div style="position:absolute;top:50%;left:{{ pct }};width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;background:#fff;box-shadow:0 0 0 3px rgba(47,128,255,.35)"></div>
|
||
</div>
|
||
</div>
|
||
<span style="width:36px">{{ durStr }}</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:6px">
|
||
<button onClick="{{ toggleQueue }}" aria-label="Очередь" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:{{ queueColor }}" style-hover="background:var(--hover)"><i style="font-size:22px">queue_music</i></button>
|
||
<button onClick="{{ toggleMute }}" aria-label="Громкость" style="width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover)"><i style="font-size:22px">{{ volIcon }}</i></button>
|
||
<div onClick="{{ onVol }}" style="width:110px;height:14px;display:flex;align-items:center;cursor:pointer">
|
||
<div style="width:100%;height:4px;border-radius:2px;background:var(--line2)"><div style="height:100%;width:{{ volPct }};border-radius:2px;background:var(--text2)"></div></div>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
</sc-if>
|
||
</div>
|
||
|
||
<sc-if value="{{ queueOpen }}">
|
||
<div style="position:absolute;top:68px;right:8px;bottom:{{ drawerBottom }};width:380px;display:flex;flex-direction:column;background:var(--elev);border:1px solid var(--line2);border-radius:20px;box-shadow:var(--shadow);z-index:40;animation:pipuIn .16s ease-out;overflow:hidden">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 16px 12px 20px">
|
||
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:17px">Очередь</span><span style="font-size:13px;color:var(--text2)">{{ srcLabel }}</span></div>
|
||
<button onClick="{{ toggleQueue }}" aria-label="Закрыть" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)" style-hover="background:var(--hover)"><i>close</i></button>
|
||
</div>
|
||
<div style="flex:1;overflow:auto;padding:0 8px 8px">
|
||
<sc-for list="{{ queueRows }}" as="r">
|
||
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:12px;padding:6px 10px;border-radius:12px;cursor:pointer;background:{{ r.bg }}" style-hover="background:var(--hover)">
|
||
<div style="width:40px;height:40px;border-radius:8px;background:{{ r.cover }};display:flex;align-items:center;justify-content:center"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if></div>
|
||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:14px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span style="font-size:12px;color:var(--text2)">{{ r.artist }}</span></div>
|
||
<span style="font-size:12px;color:var(--text3);font-variant-numeric:tabular-nums">{{ r.dur }}</span>
|
||
</div>
|
||
</sc-for>
|
||
<sc-if value="{{ waveOn }}">
|
||
<div style="display:flex;align-items:center;gap:10px;margin:8px 10px 4px;padding:12px;border-radius:12px;background:var(--accent-soft);font-size:13px;line-height:1.45;color:var(--text2)"><i style="font-size:20px;color:var(--accent2)">all_inclusive</i>Волна бесконечна — следующие треки подбираются по ходу, с учётом ваших реакций.</div>
|
||
</sc-if>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ dlg.any }}">
|
||
<div onClick="{{ dlg.close }}" style="position:absolute;inset:0;z-index:100;background:var(--overlay);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;animation:pipuIn .14s ease-out">
|
||
<div onClick="{{ dlg.stop }}" style="width:440px;max-width:calc(100% - 32px);display:flex;flex-direction:column;gap:18px;padding:28px;border-radius:22px;background:var(--elev);border:1px solid var(--line2);box-shadow:var(--shadow);box-sizing:border-box">
|
||
<sc-if value="{{ dlg.create }}">
|
||
<div style="font-family:Unbounded;font-size:22px;font-weight:600">Новый пользователь</div>
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Логин</span><input value="{{ f.nuLogin }}" onChange="{{ fh.nuLogin }}" placeholder="например, katya" style="height:44px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid {{ nu.border }};outline:none;font-size:15px;font-family:'JetBrains Mono'" style-focus="border-color:var(--accent)"><sc-if value="{{ nu.err }}"><span style="font-size:12px;color:var(--danger)">{{ nu.err }}</span></sc-if></label>
|
||
<label style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Имя</span><input value="{{ f.nuName }}" onChange="{{ fh.nuName }}" placeholder="Как показывать в списке" style="height:44px;padding:0 14px;border-radius:12px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:15px" style-focus="border-color:var(--accent)"></label>
|
||
<sc-if value="{{ nu.canRole }}">
|
||
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-size:13px;color:var(--text2)">Роль</span><div style="display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--surface2);align-self:flex-start"><sc-for list="{{ nu.roles }}" as="l"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 14px;border-radius:8px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button></sc-for></div></div>
|
||
</sc-if>
|
||
<span style="font-size:13px;line-height:1.5;color:var(--text2)">Пароль сгенерируется автоматически. При первом входе пользователь задаст свой.</span>
|
||
<div style="display:flex;justify-content:flex-end;gap:8px"><button onClick="{{ dlg.close }}" style="height:42px;padding:0 16px;border-radius:12px;font-size:14px;font-weight:500;color:var(--text2)" style-hover="background:var(--hover)">Отмена</button><button onClick="{{ nu.onCreate }}" style="height:42px;padding:0 18px;border-radius:12px;background:var(--accent);color:#fff;font-weight:600;font-size:14px">Создать</button></div>
|
||
</sc-if>
|
||
<sc-if value="{{ dlg.temp }}">
|
||
<div style="display:flex;flex-direction:column;gap:6px"><div style="font-family:Unbounded;font-size:22px;font-weight:600">Временный пароль</div><span style="font-size:14px;color:var(--text2)">Для: {{ tp.who }}</span></div>
|
||
<div style="display:flex;align-items:center;gap:10px;padding:14px 14px 14px 18px;border-radius:14px;background:var(--surface2);border:1px solid var(--line2)"><span style="flex:1;font-family:'JetBrains Mono';font-size:20px;font-weight:600;letter-spacing:.04em">{{ tp.pw }}</span><button onClick="{{ tp.onCopy }}" style="display:flex;align-items:center;gap:6px;height:36px;padding:0 12px 0 8px;border-radius:10px;background:var(--accent);color:#fff;font-size:13px;font-weight:600"><i style="font-size:18px">content_copy</i>Копировать</button></div>
|
||
<div style="display:flex;align-items:flex-start;gap:8px;font-size:13px;line-height:1.5;color:var(--warn)"><i style="font-size:18px">visibility_off</i>Пароль показывается один раз. При входе пользователь обязан будет задать новый.</div>
|
||
<button onClick="{{ dlg.close }}" style="align-self:flex-end;height:42px;padding:0 18px;border-radius:12px;background:var(--surface3);font-weight:600;font-size:14px">Готово</button>
|
||
</sc-if>
|
||
<sc-if value="{{ dlg.reject }}">
|
||
<div style="font-family:Unbounded;font-size:22px;font-weight:600">{{ rj.title }}</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px"><sc-for list="{{ rj.quick }}" as="r"><button onClick="{{ r.onClick }}" style="height:32px;padding:0 12px;border-radius:999px;font-size:13px;background:{{ r.bg }};border:1px solid {{ r.border }}">{{ r.label }}</button></sc-for></div>
|
||
<textarea value="{{ rj.reason }}" onChange="{{ fh.reason }}" placeholder="Причина — её увидит автор предложения" rows="3" style="padding:12px 14px;border-radius:12px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:14px;resize:vertical" style-focus="border-color:var(--accent)"></textarea>
|
||
<div style="display:flex;justify-content:flex-end;gap:8px"><button onClick="{{ dlg.close }}" style="height:42px;padding:0 16px;border-radius:12px;font-size:14px;font-weight:500;color:var(--text2)" style-hover="background:var(--hover)">Отмена</button><button onClick="{{ rj.onConfirm }}" style="height:42px;padding:0 18px;border-radius:12px;background:var(--danger);color:#fff;font-weight:600;font-size:14px">Отклонить</button></div>
|
||
</sc-if>
|
||
</div>
|
||
</div>
|
||
</sc-if>
|
||
|
||
<sc-if value="{{ toast }}">
|
||
<div style="position:absolute;left:50%;bottom:{{ toastBottom }};transform:translateX(-50%);display:flex;align-items:center;gap:10px;padding:12px 18px;border-radius:14px;background:var(--text);color:var(--bg);font-size:14px;font-weight:500;box-shadow:var(--shadow);z-index:200;animation:pipuIn .18s ease-out;max-width:560px"><i style="font-size:20px">check_circle</i>{{ toast }}</div>
|
||
</sc-if>
|
||
</sc-if>
|
||
</div>
|
||
</x-dc>
|
||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["dark","light"],"default":"dark","tsType":"string","section":"Вид"},"waveVariant":{"editor":"enum","options":["orb","rings","liquid"],"default":"orb","tsType":"string","section":"Вид"},"ambient":{"editor":"boolean","default":true,"tsType":"boolean","section":"Вид"},"homeLayout":{"editor":"enum","options":["center","split"],"default":"center","tsType":"string","section":"Варианты"},"shell":{"editor":"enum","options":["classic","floating"],"default":"classic","tsType":"string","section":"Варианты"},"player":{"editor":"enum","options":["bar","island"],"default":"bar","tsType":"string","section":"Варианты"},"queueLayout":{"editor":"enum","options":["grouped","split"],"default":"grouped","tsType":"string","section":"Варианты"},"role":{"editor":"enum","options":["admin","moderator","user"],"default":"admin","tsType":"string","section":"Прототип"},"startScreen":{"editor":"enum","options":["login","app"],"default":"app","tsType":"string","section":"Прототип"}}">
|
||
const USERS0 = [
|
||
{ id: 1, name: 'Алексей Морозов', login: 'admin', role: 'admin', status: 'active', last: 'сейчас в сети' },
|
||
{ id: 2, name: 'Илья Сафонов', login: 'ilya', role: 'moderator', status: 'active', last: '12 мин назад' },
|
||
{ id: 3, name: 'Мария Котова', login: 'masha', role: 'moderator', status: 'active', last: 'вчера' },
|
||
{ id: 4, name: 'Анна Белова', login: 'anna', role: 'user', status: 'active', last: '2 ч назад' },
|
||
{ id: 5, name: 'Дмитрий Орлов', login: 'dima', role: 'user', status: 'active', last: '3 дня назад' },
|
||
{ id: 6, name: 'Олег Ким', login: 'oleg', role: 'user', status: 'blocked', last: '2 нед. назад' },
|
||
{ id: 7, name: 'Света Ли', login: 'sveta', role: 'user', status: 'mustchange', last: '—' },
|
||
{ id: 8, name: 'Никита Громов', login: 'nik', role: 'user', status: 'active', last: '5 ч назад' }
|
||
];
|
||
const SOURCES0 = [
|
||
{ id: 'nas', name: 'NAS — музыка', type: 'nfs', server: '192.168.1.20', export: '/volume1/music', opts: 'nfsvers=4.1,soft,timeo=150', path: '', status: 'ok', tracks: '11 284', accepts: true, last: 'сегодня, 04:00' },
|
||
{ id: 'old', name: 'Старый NAS', type: 'nfs', server: '192.168.1.31', export: '/export/flac', opts: 'nfsvers=4.1,soft', path: '', status: 'error', msg: 'stat: нет ответа за 15 с — треки помечены недоступными', tracks: '2 140', accepts: false, last: '3 дня назад' },
|
||
{ id: 'vinyl', name: 'Оцифровка винила', type: 'local', server: '', export: '', opts: '', path: 'vinyl', status: 'ok', tracks: '412', accepts: false, last: 'вчера, 04:00' }
|
||
];
|
||
const PATTERNS0 = [
|
||
'^(?P<artist>[^/]+)/(?P<year>\\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\\d)-)?(?P<track>\\d{1,3}) - (?P<title>[^/]+)\\.\\w+$',
|
||
'^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \\((?P<year>\\d{4})\\)/(?P<track>\\d+)\\. (?P<title>[^/]+)\\.\\w+$',
|
||
'^Compilations/(?P<album>[^/]+)/(?P<track>\\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\\.\\w+$',
|
||
'^(?P<skip>_incoming)/'
|
||
];
|
||
const PREVIEW = [
|
||
'Radiohead/2007 - In Rainbows/04 - Weird Fishes_Arpeggi.flac',
|
||
'Кино/1988 - Группа крови/01 - Группа крови.flac',
|
||
'Daft Punk/2013 - Random Access Memories/1-08 - Get Lucky.flac',
|
||
'Электроника/Bonobo/Migration (2017)/03. Outlier.mp3',
|
||
'Compilations/Ночь на Петроградке/07 - Сплин - Орбит без сахара.mp3',
|
||
'_incoming/rip/track03.wav',
|
||
'Земфира/Прости меня моя любовь/Хочешь.flac'
|
||
];
|
||
const DEF_TPL = '{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}';
|
||
const Q0 = [
|
||
{ id: 'q1', artist: 'Moderat', album: 'III', year: '2016', c1: '#E6E1D6', c2: '#2E2E33', by: 'Илья Сафонов', when: '2 ч назад', note: 'Исполнитель уже есть · новый альбом', fmt: 'FLAC', q: '16 бит / 44,1 кГц', ext: 'flac', tracks: ['Eating Hooks', 'Running', 'Finder', 'Ghostmother', 'Reminder', 'The Fool', 'Intruder', 'Animal Trails'] },
|
||
{ id: 'q2', artist: 'Molchat Doma', album: 'Этажи', year: '2018', c1: '#9AA6B2', c2: '#161B21', by: 'Илья Сафонов', when: 'вчера', note: 'Новый исполнитель · новый альбом', fmt: 'FLAC', q: '24 бит / 48 кГц', ext: 'flac', tracks: ['Тоска', 'Клетка', 'Судно (Борис Рыжий)', 'Танцевать', 'Discoteque', 'Звёзды'] },
|
||
{ id: 'q3', artist: 'Земфира', album: 'Live в Олимпийском', year: '2007', c1: '#E0507F', c2: '#1E0A1A', by: 'Мария Котова', when: '3 дня назад', note: 'Исполнитель уже есть · новый альбом', fmt: 'MP3', q: '320 кбит/с', ext: 'mp3', tracks: [['Хочешь?', 'dup', 'Похоже на «Хочешь?» · Прости меня моя любовь, 2000 — другая версия?'], ['Искала', 'dup', 'Похоже на «Искала» · Прости меня моя любовь, 2000'], 'Небо Лондона', 'Бесконечность'] },
|
||
{ id: 'q4', artist: 'Radiohead', album: 'In Rainbows', year: '2007', c1: '#F2A23A', c2: '#1C3B6E', by: 'Мария Котова', when: '3 дня назад', note: 'Альбом уже есть · замена файла', fmt: 'FLAC', q: '24 бит / 96 кГц', ext: 'flac', tracks: [['Nude', 'better', 'Сейчас в библиотеке MP3 320 кбит/с — заменится на FLAC 24/96', 3]] }
|
||
];
|
||
const PROP0 = [
|
||
{ id: 'p1', name: 'Moderat — III (2016)', files: 8, size: '312 МБ', status: 'pending_review', date: 'сегодня, 10:14' },
|
||
{ id: 'p2', name: 'Molchat Doma — Этажи (2018)', files: 6, size: '248 МБ', status: 'pending_review', date: 'вчера, 22:40' },
|
||
{ id: 'p3', name: 'Bonobo — Fragments (2022)', files: 12, size: '486 МБ', status: 'imported', date: '12 окт' },
|
||
{ id: 'p4', name: 'Сплин — Сигнал из космоса (2009)', files: 11, size: '98 МБ', status: 'rejected', reason: 'Низкое качество: MP3 128 кбит/с — нужен lossless', date: '10 окт' },
|
||
{ id: 'p5', name: 'Daft Punk — Get Lucky (Radio Edit).mp3', files: 1, size: '9 МБ', status: 'duplicate', reason: 'Уже есть: Daft Punk — Get Lucky', date: '9 окт' },
|
||
{ id: 'p6', name: 'Massive Attack — 100th Window (2003)', files: 9, size: '402 МБ', status: 'withdrawn', date: '2 окт' }
|
||
];
|
||
const BATCHES = [
|
||
[['Pompeya — Tropical (2012)/01 - Power.flac', 34], ['Pompeya — Tropical (2012)/02 - Y.A.H.F..flac', 29], ['Pompeya — Tropical (2012)/03 - Tropical.flac', 31], ['Pompeya — Tropical (2012)/04 - 90.flac', 27], ['Pompeya — Tropical (2012)/05 - Slaves.flac', 33], ['Pompeya — Tropical (2012)/01 - Power (копия).flac', 34, 'Дубль файла в этой же пачке: 01 - Power.flac'], ['Radiohead - Nude.mp3', 9, 'Уже есть: Radiohead — Nude · In Rainbows']],
|
||
[['Kedr Livanskiy — Your Need (2019)/01 - Your Need.flac', 38], ['Kedr Livanskiy — Your Need (2019)/02 - Sky Kisses.flac', 41], ['Kedr Livanskiy — Your Need (2019)/03 - Kiska.flac', 36], ['Кино - Война.mp3', 8, 'Уже есть: Кино — Война · Группа крови']]
|
||
];
|
||
const ST = {
|
||
uploading: ['Загружается', 'var(--accent2)', 'var(--accent-soft)'], processing: ['Обработка', 'var(--violet)', 'var(--violet-soft)'],
|
||
pending_review: ['На рассмотрении', 'var(--warn)', 'var(--warn-soft)'], approved: ['Одобрено', 'var(--ok)', 'var(--ok-soft)'],
|
||
imported: ['Добавлено', 'var(--ok)', 'var(--ok-soft)'], rejected: ['Отклонено', 'var(--danger)', 'var(--danger-soft)'],
|
||
duplicate: ['Уже есть', 'var(--text2)', 'var(--hover)'], withdrawn: ['Отозвано', 'var(--text3)', 'var(--hover)']
|
||
};
|
||
const ROLE = { admin: ['Администратор', 'var(--violet)', 'var(--violet-soft)'], moderator: ['Модератор', 'var(--accent2)', 'var(--accent-soft)'], user: ['Пользователь', 'var(--text2)', 'var(--hover)'] };
|
||
const USTAT = { active: ['Активен', 'var(--ok)'], blocked: ['Заблокирован', 'var(--danger)'], mustchange: ['Ждёт смены пароля', 'var(--warn)'] };
|
||
const F1 = "'FILL' 1", F0 = "'FILL' 0";
|
||
const ini = n => n.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||
const genPw = () => { const c = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let s = ''; for (let i = 0; i < 12; i++) s += c[Math.floor(Math.random() * c.length)]; return s.slice(0, 4) + '-' + s.slice(4, 8) + '-' + s.slice(8); };
|
||
|
||
class Component extends DCLogic {
|
||
state = { ready: false };
|
||
mainRef = React.createRef();
|
||
searchRef = React.createRef();
|
||
_fh = {};
|
||
|
||
componentDidMount() {
|
||
const wait = () => (window.PIPU ? this.init() : setTimeout(wait, 40));
|
||
wait();
|
||
this.iv = setInterval(() => this.tick(), 250);
|
||
this.kd = e => this.onKey(e);
|
||
window.addEventListener('keydown', this.kd);
|
||
}
|
||
componentWillUnmount() { clearInterval(this.iv); window.removeEventListener('keydown', this.kd); }
|
||
|
||
init() {
|
||
const D = window.PIPU;
|
||
let sv = null; try { sv = JSON.parse(localStorage.getItem('pipu-web-player') || 'null'); } catch (e) {}
|
||
const ok = sv && D.TR[sv.trackId];
|
||
const alb = D.ALB['rh-ir'].tracks;
|
||
const hist = []; let k = 3;
|
||
[['Сегодня', 5, 18], ['Вчера', 7, 22], ['17 октября', 6, 21]].forEach(([d, n, h]) => { let m = 40; for (let j = 0; j < n; j++) { k = (k * 7 + 5) % D.ALL.length; hist.push({ id: D.ALL[k], day: d, time: String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') }); m -= 13; if (m < 0) { m += 60; h--; } } });
|
||
const qg = Q0.map(g => ({ ...g, tracks: g.tracks.map((t, i) => { const a = Array.isArray(t) ? t : [t]; const h = D.H(g.id + a[0]); return { id: g.id + '-' + i, title: a[0], flag: a[1] || null, note: a[2] || '', n: a[3] || i + 1, size: g.fmt === 'MP3' ? 8 + h % 5 : g.q.startsWith('24') ? 70 + h % 50 : 24 + h % 18 }; }) }));
|
||
this.setState({
|
||
ready: true, auth: this.props.startScreen === 'app' ? null : 'login', page: 'home', params: {},
|
||
queue: ok && sv.queue ? sv.queue.filter(id => D.TR[id]) : alb, qi: ok ? sv.qi || 0 : 3, trackId: ok ? sv.trackId : alb[3], pos: ok ? sv.pos || 0 : 83,
|
||
source: (ok && sv.source) || { type: 'list', label: 'Альбом · In Rainbows' }, waveOn: ok ? !!sv.waveOn : false, playing: false, vol: 0.7, muted: false,
|
||
activity: null, mood: null,
|
||
likes: ['rh-ir-6', 'ma-mz-2', 'kino-gk-0', 'mod-2-1', 'bon-mg-2', 'dp-ram-4', 'zem-pm-0', 'kino-zs-4', 'spl-ga-0', 'rh-ir-3', 'bon-mg-0', 'ma-mz-0'],
|
||
dislikes: ['dp-ram-1', 'spl-ga-3', 'kino-gk-5'], history: hist,
|
||
q: '', sOpen: false, sIdx: -1, searchTab: 'all', queueOpen: false, profileOpen: false, theme: null, lang: 'ru', crossfade: 2, normalize: true,
|
||
f: {}, err: {}, showPw: false,
|
||
users: USERS0, uq: '', uRole: 'all', uMenu: null, dlg: null, tempPw: null, tempFor: '',
|
||
sources: SOURCES0, srcSel: 'nas', patterns: PATTERNS0, tagPrio: 'tags', writeTpl: DEF_TPL, tplChecked: false, testing: false, testRes: null, dragIdx: null,
|
||
scan: { pct: 62, running: true }, ana: 8912,
|
||
uploads: [], batchN: 0, proposals: PROP0, dragOver: false,
|
||
qGroups: qg, qSel: {}, qFilter: 'all', qGroup: 'q1', qPlay: null, rejectIds: [],
|
||
adm: { limit: 10, perUser: false, perUserLimit: 3, maxFile: 2, defSrc: 'nas', upgrade: false },
|
||
toast: null
|
||
});
|
||
}
|
||
|
||
tick() {
|
||
const s = this.state, D = window.PIPU; if (!s.ready) return;
|
||
const u = {};
|
||
if (s.playing && !s.auth) { const t = D.TR[s.trackId]; const np = s.pos + 0.25; if (np >= t.dur) { this.next(); return; } u.pos = np; }
|
||
if (s.page === 'library' && s.scan.running) { const p = s.scan.pct + 0.12; u.scan = p >= 100 ? { pct: 100, running: false } : { pct: p, running: true }; u.ana = Math.min(11284, s.ana + (Math.random() < 0.6 ? 1 : 0)); }
|
||
if (s.uploads.some(x => x.status === 'uploading' || x.status === 'processing')) {
|
||
let active = 0;
|
||
u.uploads = s.uploads.map(x => {
|
||
if (x.status === 'uploading') { if (active++ >= 4) return x; return x.progress >= 100 ? { ...x, status: 'processing', pt: 0 } : { ...x, progress: Math.min(100, x.progress + 4 + Math.random() * 9) }; }
|
||
if (x.status === 'processing') return x.pt >= 6 ? { ...x, status: 'pending_review' } : { ...x, pt: x.pt + 1 };
|
||
return x;
|
||
});
|
||
}
|
||
if (Object.keys(u).length) this.setState(u);
|
||
this._t = (this._t || 0) + 1;
|
||
if (this._t % 4 === 0) { try { localStorage.setItem('pipu-web-player', JSON.stringify({ trackId: s.trackId, pos: s.pos, queue: s.queue.slice(0, 60), qi: s.qi, source: s.source, waveOn: s.waveOn })); } catch (e) {} }
|
||
}
|
||
|
||
onKey(e) {
|
||
const s = this.state; if (!s.ready || s.auth) return;
|
||
const tag = (e.target.tagName || '').toLowerCase(), typing = tag === 'input' || tag === 'textarea' || tag === 'select';
|
||
if ((e.key === '/' && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k')) { e.preventDefault(); this.searchRef.current && this.searchRef.current.focus(); return; }
|
||
if (typing) return;
|
||
if (e.code === 'Space') { e.preventDefault(); this.togglePlay(); }
|
||
else if (e.key === 'ArrowRight') { e.shiftKey ? this.next() : this.seekBy(10); }
|
||
else if (e.key === 'ArrowLeft') { e.shiftKey ? this.prev() : this.seekBy(-10); }
|
||
else if (e.key === 'l' || e.key === 'L' || e.key === 'д') this.toggleLike(s.trackId);
|
||
else if (e.key === 'Escape') this.setState({ queueOpen: false, profileOpen: false, dlg: null, uMenu: null });
|
||
}
|
||
|
||
toastMsg(m) { clearTimeout(this._tt); this.setState({ toast: m }); this._tt = setTimeout(() => this.setState({ toast: null }), 2800); }
|
||
go(page, params = {}) { this.setState({ page, params, sOpen: false, profileOpen: false, queueOpen: false, uMenu: null }); if (this.mainRef.current) this.mainRef.current.scrollTop = 0; }
|
||
fh(k) { return this._fh[k] || (this._fh[k] = e => this.setState(s => ({ f: { ...s.f, [k]: e.target.value }, err: { ...s.err, [k]: null } }))); }
|
||
|
||
addHist(h, id) { if (h[0] && h[0].id === id) return h; const d = new Date(); return [{ id, day: 'Сегодня', time: String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') }, ...h]; }
|
||
setTrack(qi, queue, extra = {}) { const id = queue[qi]; this.setState(s => ({ queue, qi, trackId: id, pos: 0, playing: true, qPlay: null, history: this.addHist(s.history, id), ...extra })); }
|
||
playList(list, i, label) { this.setTrack(i, list, { waveOn: false, source: { type: 'list', label } }); }
|
||
wopts(extra = {}) { const s = this.state; return { activity: s.activity, mood: s.mood, dislikes: s.dislikes, likes: s.likes, ...extra }; }
|
||
startWave(artistId) { const q = window.PIPU.waveBatch(12, this.wopts({ artistId })); this.setTrack(0, q, { waveOn: true, source: { type: 'wave', artistId: artistId || null } }); }
|
||
next() {
|
||
const s = this.state, D = window.PIPU; let q = s.queue, i = s.qi + 1;
|
||
if (s.waveOn && i >= q.length - 2) q = [...q, ...D.waveBatch(8, this.wopts({ recent: q.slice(-20), artistId: s.source.artistId }))];
|
||
if (i >= q.length) { this.setState({ playing: false, pos: 0 }); return; }
|
||
this.setTrack(i, q);
|
||
}
|
||
prev() { const s = this.state; if (s.pos > 3 || s.qi === 0) { this.setState({ pos: 0 }); return; } this.setTrack(s.qi - 1, s.queue); }
|
||
togglePlay() { this.setState(s => ({ playing: !s.playing, qPlay: null })); }
|
||
seekBy(d) { const D = window.PIPU; this.setState(s => ({ pos: D.clamp(s.pos + d, 0, D.TR[s.trackId].dur - 1) })); }
|
||
onWave() { const s = this.state; if (s.waveOn && !s.source.artistId) this.togglePlay(); else this.startWave(); }
|
||
setCtx(k, v) {
|
||
this.setState(s => ({ [k]: s[k] === v ? null : v }), () => {
|
||
const s = this.state; if (!s.waveOn) return;
|
||
const keep = s.queue.slice(0, s.qi + 1);
|
||
this.setState({ queue: [...keep, ...window.PIPU.waveBatch(10, this.wopts({ recent: keep.slice(-20) }))] });
|
||
});
|
||
}
|
||
toggleLike(id) { this.setState(s => ({ likes: s.likes.includes(id) ? s.likes.filter(x => x !== id) : [id, ...s.likes], dislikes: s.dislikes.filter(x => x !== id) })); }
|
||
toggleDislike(id) {
|
||
const s = this.state, was = s.dislikes.includes(id);
|
||
this.setState({ dislikes: was ? s.dislikes.filter(x => x !== id) : [id, ...s.dislikes], likes: s.likes.filter(x => x !== id) });
|
||
if (!was && id === s.trackId && s.waveOn) { setTimeout(() => this.next(), 0); this.toastMsg('Больше не попадётся в волне — включаем следующий'); }
|
||
else if (!was) this.toastMsg('Трек скрыт из рекомендаций');
|
||
else this.toastMsg('Дизлайк снят — трек снова может попасть в волну');
|
||
}
|
||
openSearch() { const q = this.state.q.trim(); if (!q) return; this.go('search', { q }); this.setState({ searchTab: 'all' }); this.searchRef.current && this.searchRef.current.blur(); }
|
||
|
||
doLogin() {
|
||
const f = this.state.f, l = (f.login || '').trim(), p = f.pass || '';
|
||
if (!l || !p) return this.setState({ err: { login: 'Введите логин и пароль' } });
|
||
if (l === 'admin' && p === 'admin') return this.setState({ auth: 'setupAdmin', f: {}, err: {} });
|
||
if (p === 'reset') return this.setState({ auth: 'setupPassword', f: {}, err: {} });
|
||
if (p.length < 4) return this.setState({ err: { login: 'Неверный логин или пароль' } });
|
||
this.setState({ auth: null, f: {}, err: {}, page: 'home' });
|
||
}
|
||
doSetup() {
|
||
const s = this.state, f = s.f, e = {};
|
||
if (s.auth === 'setupAdmin') { if (!(f.nlogin || '').trim()) e.nlogin = 'Придумайте логин'; else if (f.nlogin.trim() === 'admin') e.nlogin = 'Стандартный логин нужно сменить'; }
|
||
if ((f.np || '').length < 8) e.np = 'Минимум 8 символов';
|
||
if (f.np !== f.np2) e.np2 = 'Пароли не совпадают';
|
||
if (Object.keys(e).length) return this.setState({ err: e });
|
||
this.setState({ auth: null, f: {}, err: {}, page: 'home' }); this.toastMsg('Готово — данные сохранены');
|
||
}
|
||
strength(p) {
|
||
p = p || ''; let sc = 0; if (p.length >= 8) sc++; if (p.length >= 12) sc++; if (/[a-zа-я]/.test(p) && /[A-ZА-Я]/.test(p)) sc++; if (/\d/.test(p) && /[^\w]/.test(p)) sc++;
|
||
const lv = [['Слишком короткий', 'var(--text3)'], ['Слабый', 'var(--danger)'], ['Средний', 'var(--warn)'], ['Хороший', 'var(--accent2)'], ['Надёжный', 'var(--ok)']][p ? Math.max(1, sc) : 0];
|
||
return { label: p ? lv[0] : '', color: lv[1], bars: [0, 1, 2, 3].map(i => ({ bg: p && i < Math.max(1, sc) ? lv[1] : 'var(--line2)' })) };
|
||
}
|
||
|
||
qPath(g, tr) {
|
||
const D = window.PIPU, s = this.state;
|
||
const p = D.fillTpl(s.writeTpl, { albumartist: g.artist, artist: g.artist, year: g.year, album: g.album, disc: '', track: tr.n, title: tr.title, ext: g.ext }).split('/');
|
||
const A = Object.values(D.ART).find(a => a.name.toLowerCase() === (p[0] || '').toLowerCase());
|
||
if (A) p[0] = A.name;
|
||
const albEx = !!A && p.length > 2 && A.albums.some(id => (D.ALB[id].year + ' - ' + D.ALB[id].title) === p[1]);
|
||
return { parts: p, artEx: !!A, albEx };
|
||
}
|
||
approve(ids, verb) {
|
||
const set = new Set(ids);
|
||
this.setState(s => ({ qGroups: s.qGroups.map(g => ({ ...g, tracks: g.tracks.filter(t => !set.has(t.id)) })).filter(g => g.tracks.length), qSel: {}, dlg: null, f: { ...s.f, reason: '' } }));
|
||
const n = ids.length, P = window.PIPU.plural;
|
||
this.toastMsg(verb === 'reject' ? `${n} ${P(n, ['трек отклонён', 'трека отклонены', 'треков отклонено'])} — автор увидит причину` : `${n} ${P(n, ['трек одобрен', 'трека одобрено', 'треков одобрено'])} — файлы раскладываются в хранилище`);
|
||
}
|
||
addBatch() {
|
||
const s = this.state, b = BATCHES[s.batchN % BATCHES.length];
|
||
const ups = b.map(([path, mb, dup], i) => ({ id: 'u' + Date.now() + i, path, size: mb, status: dup ? 'duplicate' : 'uploading', progress: 0, dupOf: dup || '' }));
|
||
const d = ups.filter(u => u.status === 'duplicate').length;
|
||
this.setState({ uploads: [...ups, ...s.uploads], batchN: s.batchN + 1, dragOver: false });
|
||
this.toastMsg(`Проверено ${ups.length} файлов: ${ups.length - d} загружаются, ${d} — уже есть, место не занято`);
|
||
}
|
||
|
||
adminVals({ D, s, P, isAdmin, isMod, seg, plural }) {
|
||
const o = {};
|
||
const box = (on) => ({ selBg: on ? 'var(--accent)' : 'transparent', selBorder: on ? 'var(--accent)' : 'var(--line2)', selIcon: on ? 'check' : '' });
|
||
const tg = (on) => ({ bg: on ? 'var(--accent)' : 'var(--line2)', x: on ? '21px' : '3px' });
|
||
const stb = k => ({ stLabel: ST[k][0], stColor: ST[k][1], stBg: ST[k][2] });
|
||
const pg = s.page;
|
||
if (pg === 'users') {
|
||
const canManage = u => u.role !== 'admin' && (isAdmin || u.role === 'user');
|
||
const list = s.users.filter(u => (s.uRole === 'all' || u.role === s.uRole) && (!s.uq || (u.name + ' ' + u.login).toLowerCase().includes(s.uq.toLowerCase())));
|
||
o.uu = {
|
||
q: s.uq, onQ: e => this.setState({ uq: e.target.value }),
|
||
tabs: [['all', 'Все'], ['admin', 'Админы'], ['moderator', 'Модераторы'], ['user', 'Пользователи']].map(([k, l]) => ({ label: l, count: k === 'all' ? s.users.length : s.users.filter(u => u.role === k).length, onClick: () => this.setState({ uRole: k }), bg: s.uRole === k ? 'var(--surface3)' : 'transparent', color: s.uRole === k ? 'var(--text)' : 'var(--text2)' })),
|
||
hint: isAdmin ? 'Создание пользователей и модераторов, роли, сброс пароля, блокировка' : 'Как модератор вы можете создавать обычных пользователей, сбрасывать им пароль и блокировать',
|
||
onCreate: () => this.setState({ dlg: 'create', nuRole: 'user', f: { ...s.f, nuLogin: '', nuName: '' }, err: {} }),
|
||
rows: list.map(u => {
|
||
const bl = u.status === 'blocked', acts = [
|
||
{ icon: 'lock_reset', label: 'Сбросить пароль', color: 'var(--text)', onClick: () => this.setState({ users: s.users.map(x => x.id === u.id ? { ...x, status: 'mustchange' } : x), dlg: 'temp', tempPw: genPw(), tempFor: u.name, uMenu: null }) },
|
||
{ icon: bl ? 'lock_open' : 'block', label: bl ? 'Разблокировать' : 'Заблокировать', color: bl ? 'var(--text)' : 'var(--danger)', onClick: () => { this.setState({ users: s.users.map(x => x.id === u.id ? { ...x, status: bl ? 'active' : 'blocked' } : x), uMenu: null }); this.toastMsg(bl ? u.name + ' снова может входить' : u.name + ' заблокирован, сессии завершены'); } }
|
||
];
|
||
if (isAdmin) acts.splice(1, 0, { icon: 'shield_person', label: u.role === 'moderator' ? 'Снять роль модератора' : 'Сделать модератором', color: 'var(--text)', onClick: () => { this.setState({ users: s.users.map(x => x.id === u.id ? { ...x, role: u.role === 'moderator' ? 'user' : 'moderator' } : x), uMenu: null }); } });
|
||
return { name: u.name, login: u.login, ini: ini(u.name), last: u.last, roleLabel: ROLE[u.role][0], roleColor: ROLE[u.role][1], roleBg: ROLE[u.role][2], stLabel: USTAT[u.status][0], stColor: USTAT[u.status][1], op: bl ? 0.55 : 1, canManage: canManage(u), menuOpen: s.uMenu === u.id, onMenu: e => { e.stopPropagation(); this.setState({ uMenu: s.uMenu === u.id ? null : u.id }); }, acts };
|
||
})
|
||
};
|
||
}
|
||
if (pg === 'sources') {
|
||
const src = s.sources.find(x => x.id === s.srcSel) || s.sources[0];
|
||
const upd = (k, v) => this.setState(st => ({ sources: st.sources.map(x => x.id === src.id ? { ...x, [k]: v } : x), testRes: null }));
|
||
const res = s.patterns.map(p => D.compile(p));
|
||
const prev = PREVIEW.map(path => {
|
||
for (let k = 0; k < res.length; k++) {
|
||
const re = res[k]; if (re instanceof Error) continue;
|
||
const m = re.exec(path);
|
||
if (m) { const g = m.groups || {}; const skip = g.skip != null; return { path, g, k, segs: D.segments(path, m), skip, badge: skip ? 'Пропуск' : 'Выражение #' + (k + 1), bc: skip ? 'var(--text2)' : 'var(--ok)', bb: skip ? 'var(--hover)' : 'var(--ok-soft)', fields: skip ? [] : Object.entries(g).filter(([, v]) => v != null).map(([n, v]) => ({ k: D.GL[n] || n, v, c: D.GC[n] || 'var(--text)' })) }; }
|
||
}
|
||
return { path, segs: [{ t: path, c: 'var(--danger)' }], badge: 'Не совпало', bc: 'var(--danger)', bb: 'var(--danger-soft)', fields: [], none: true };
|
||
});
|
||
const chk = prev.filter(r => !r.none && !r.skip).map(r => { const gen = D.fillTpl(s.writeTpl, { ...r.g, ext: r.path.split('.').pop() }); return { real: r.path, gen, ok: gen === r.path, icon: gen === r.path ? 'check_circle' : 'cancel', c: gen === r.path ? 'var(--ok)' : 'var(--warn)' }; });
|
||
const okN = chk.filter(c => c.ok).length, pct = chk.length ? Math.round(okN / chk.length * 100) : 0;
|
||
o.src = {
|
||
list: s.sources.map(x => { const sel = x.id === src.id; return { name: x.name, addr: x.type === 'nfs' ? x.server + ':' + x.export : '/srv/pipu/library-host/' + x.path, typeLabel: x.type === 'nfs' ? 'NFS' : 'Папка на сервере', tracks: x.tracks + ' треков', stLabel: x.status === 'ok' ? 'Подключено' : 'Ошибка', stColor: x.status === 'ok' ? 'var(--ok)' : 'var(--danger)', accepts: x.accepts, err: x.status === 'error' ? x.msg : '', bg: sel ? 'var(--accent-soft)' : 'var(--surface2)', border: sel ? 'var(--accent)' : 'var(--line)', onClick: () => this.setState({ srcSel: x.id, testRes: null }) }; }),
|
||
name: src.name, server: src.server, exp: src.export, opts: src.opts, path: src.path, isNfs: src.type === 'nfs', isLocal: src.type === 'local',
|
||
onName: e => upd('name', e.target.value), onServer: e => upd('server', e.target.value), onExp: e => upd('export', e.target.value), onOpts: e => upd('opts', e.target.value), onPath: e => upd('path', e.target.value),
|
||
types: [seg(src.type === 'nfs', 'Сетевая папка NFS', () => upd('type', 'nfs')), seg(src.type === 'local', 'Папка на сервере', () => upd('type', 'local'))],
|
||
acc: tg(src.accepts), onAcc: () => upd('accepts', !src.accepts),
|
||
testLabel: s.testing ? 'Проверяем…' : 'Проверить подключение',
|
||
onTest: () => { this.setState({ testing: true, testRes: null }); setTimeout(() => { const ok = src.status === 'ok'; this.setState({ testing: false, testRes: ok ? { icon: 'check_circle', c: 'var(--ok)', b: 'var(--ok-soft)', text: 'Подключено: чтение OK' + (src.accepts ? ', запись OK' : '') + ' · в первых 50 каталогах найдено 1 248 аудиофайлов' } : { icon: 'error', c: 'var(--danger)', b: 'var(--danger-soft)', text: 'Не удалось смонтировать ' + src.server + ':' + src.export + ' — сервер не отвечает (timeo=150)' } }); }, 1100); },
|
||
testRes: s.testRes,
|
||
patterns: s.patterns.map((p, i) => {
|
||
const re = res[i];
|
||
return { n: i + 1, text: p, tokens: D.tokens(p), err: re instanceof Error ? re.message.replace(/^Invalid regular expression: /, '') : '', border: s.dragIdx === i ? 'var(--accent)' : (re instanceof Error ? 'var(--danger)' : 'var(--line2)'),
|
||
onChange: e => { const v = e.target.value; this.setState(st => ({ patterns: st.patterns.map((x, j) => j === i ? v : x), tplChecked: false })); },
|
||
onDel: () => this.setState(st => ({ patterns: st.patterns.filter((x, j) => j !== i) })),
|
||
onDragStart: e => { e.dataTransfer.effectAllowed = 'move'; this.setState({ dragIdx: i }); }, onDragOver: e => e.preventDefault(), onDragEnd: () => this.setState({ dragIdx: null }),
|
||
onDrop: e => { e.preventDefault(); const from = this.state.dragIdx; if (from == null || from === i) return this.setState({ dragIdx: null }); const a = [...this.state.patterns]; const [x] = a.splice(from, 1); a.splice(i, 0, x); this.setState({ patterns: a, dragIdx: null }); } };
|
||
}),
|
||
onAddPattern: () => this.setState(st => ({ patterns: [...st.patterns, '^(?P<artist>[^/]+)/(?P<album>[^/]+)/(?P<title>[^/]+)\\.\\w+$'] })),
|
||
prev, prevSummary: (prev.length - prev.filter(r => r.none).length) + ' из ' + prev.length + ' путей распознаны',
|
||
tags: [seg(s.tagPrio === 'tags', 'Теги файла важнее', () => this.setState({ tagPrio: 'tags' })), seg(s.tagPrio === 'path', 'Структура папок важнее', () => this.setState({ tagPrio: 'path' }))],
|
||
tpl: s.writeTpl, onTpl: e => this.setState({ writeTpl: e.target.value, tplChecked: false }),
|
||
onAutoTpl: () => { this.setState({ writeTpl: DEF_TPL, tplChecked: false }); this.toastMsg('Шаблон подобран: выражение #1 описывает 94% файлов хранилища'); },
|
||
onCheckTpl: () => this.setState({ tplChecked: true }), checked: s.tplChecked, chk,
|
||
chkSummary: okN + ' из ' + chk.length + ' путей совпали (' + pct + '%)', chkColor: pct >= 90 ? 'var(--ok)' : 'var(--warn)', chkBg: pct >= 90 ? 'var(--ok-soft)' : 'var(--warn-soft)',
|
||
chkNote: pct >= 90 ? 'Новые файлы лягут так же, как существующие' : 'Ниже порога 90%: часть хранилища разложена по другой схеме — новые файлы пойдут по шаблону выше',
|
||
onSave: () => { this.setState({ scan: { pct: 0, running: true } }); this.toastMsg('Сохранено — запущено полное пересканирование «' + src.name + '»'); }
|
||
};
|
||
}
|
||
if (pg === 'library') {
|
||
const sc = s.scan, tot = 11284;
|
||
o.lib = {
|
||
stats: [['Треков', '11 284', '+38 за неделю'], ['Исполнителей', '1 046', ''], ['Альбомов', '2 318', ''], ['Объём', '412 ГБ', 'в 3 хранилищах']].map(([l, v, n]) => ({ l, v, n })),
|
||
scanPct: sc.pct.toFixed(1) + '%', scanW: sc.pct + '%', scanFiles: Math.round(sc.pct / 100 * tot).toLocaleString('ru') + ' / ' + tot.toLocaleString('ru') + ' файлов', scanRunning: sc.running,
|
||
scanLabel: sc.running ? 'Сканирование «NAS — музыка»' : 'Сканирование завершено', scanEta: sc.running ? 'осталось ~' + Math.max(1, Math.round((100 - sc.pct) / 8)) + ' мин' : 'новых: 38 · переименовано: 4 · пропало: 0',
|
||
anaPct: Math.round(s.ana / tot * 100) + '%', anaW: (s.ana / tot * 100) + '%', anaFiles: s.ana.toLocaleString('ru') + ' / ' + tot.toLocaleString('ru') + ' треков', anaEta: '~1 ч 10 мин · 4 воркера',
|
||
onRescan: () => this.setState({ scan: { pct: 0, running: true } }),
|
||
metrics: [['Дослушивания', '74%', 'цель > 70%'], ['Ранние пропуски', '17%', 'цель < 20%'], ['Лайков на 100 треков', '6,2', 'растёт'], ['Открытия', '28%', 'цель 20–40%'], ['Медиана сессии', '42 мин', 'растёт']].map(([l, v, n]) => ({ l, v, n })),
|
||
errors: [['Radiohead/2003 - Hail to the Thief/07 - Sit Down. Stand Up.flac', 'ffprobe: повреждён заголовок FLAC', 'Сканер'], ['Земфира/2002 - 14 недель тишины/05 - Infinity.mp3', 'Теги ID3v2.4 не читаются — взяты из пути', 'Сканер'], ['Сплин/1999 - Альтависта/09 - Романс.flac', 'Таймаут анализа (120 с)', 'Аудиоанализ'], ['Старый NAS', 'Хранилище недоступно: 2 140 треков скрыты', 'Хранилище']].map(([p, e, w]) => ({ p, e, w }))
|
||
};
|
||
}
|
||
if (pg === 'upload') {
|
||
const act = s.uploads.filter(u => ['uploading', 'processing', 'pending_review'].includes(u.status));
|
||
const used = 6.2 + act.reduce((a, u) => a + u.size, 0) / 1024, lim = s.adm.limit, pct = Math.min(100, used / lim * 100);
|
||
o.up = {
|
||
usedStr: used.toFixed(1).replace('.', ','), limStr: String(lim), freeStr: Math.max(0, lim - used).toFixed(1).replace('.', ','), pct: pct + '%', barC: pct > 85 ? 'var(--warn)' : 'var(--accent)',
|
||
dzBorder: s.dragOver ? 'var(--accent)' : 'var(--line2)', dzBg: s.dragOver ? 'var(--accent-soft)' : 'var(--surface2)',
|
||
onDragOver: e => { e.preventDefault(); if (!this.state.dragOver) this.setState({ dragOver: true }); }, onDragLeave: () => this.setState({ dragOver: false }), onDrop: e => { e.preventDefault(); this.addBatch(); }, onPick: () => this.addBatch(),
|
||
hasCur: s.uploads.length > 0,
|
||
cur: s.uploads.map(u => ({ name: u.path.split('/').pop(), folder: u.path.includes('/') ? u.path.split('/')[0] : 'Отдельный файл', size: u.size + ' МБ', ...stb(u.status), isUp: u.status === 'uploading', w: u.progress + '%', note: u.dupOf, hasNote: !!u.dupOf, op: u.status === 'duplicate' ? 0.7 : 1,
|
||
canW: u.status === 'pending_review', onW: () => this.setState(st => ({ uploads: st.uploads.map(x => x.id === u.id ? { ...x, status: 'withdrawn' } : x) })) })),
|
||
props: s.proposals.map(p => ({ name: p.name, meta: p.files + ' ' + plural(p.files, ['файл', 'файла', 'файлов']) + ' · ' + p.size + ' · ' + p.date, ...stb(p.status), reason: p.reason || '', hasReason: !!p.reason, canW: p.status === 'pending_review',
|
||
onW: () => { this.setState(st => ({ proposals: st.proposals.map(x => x.id === p.id ? { ...x, status: 'withdrawn' } : x) })); this.toastMsg('Предложение отозвано — место в очереди освобождено'); } }))
|
||
};
|
||
}
|
||
if (pg === 'queue') {
|
||
const flt = s.qFilter, all = s.qGroups.flatMap(g => g.tracks);
|
||
const groups = s.qGroups.map(g => ({ ...g, vis: g.tracks.filter(t => flt === 'all' || t.flag === flt) })).filter(g => g.vis.length);
|
||
const sel = Object.keys(s.qSel).filter(k => s.qSel[k]);
|
||
const gUpd = (gid, k) => e => { const v = e.target.value; this.setState(st => ({ qGroups: st.qGroups.map(x => x.id === gid ? { ...x, [k]: v } : x) })); };
|
||
const FL = { dup: ['Возможно, уже есть', 'var(--warn)', 'var(--warn-soft)', 'content_copy'], better: ['Лучшее качество', 'var(--ok)', 'var(--ok-soft)', 'high_quality'] };
|
||
const cur = groups.find(g => g.id === s.qGroup) || groups[0];
|
||
const mapG = g => {
|
||
const ids = g.vis.map(t => t.id), allOn = ids.every(id => s.qSel[id]);
|
||
const size = g.tracks.reduce((a, t) => a + t.size, 0);
|
||
const tracks = g.vis.map(t => {
|
||
const qp = this.qPath(g, t), on = !!s.qSel[t.id], pl = s.qPlay === t.id, fl = FL[t.flag];
|
||
return { id: t.id, n: String(t.n).padStart(2, '0'), title: t.title, size: t.size + ' МБ', ...box(on), onSel: () => this.setState(st => ({ qSel: { ...st.qSel, [t.id]: !st.qSel[t.id] } })),
|
||
onTitle: e => { const v = e.target.value; this.setState(st => ({ qGroups: st.qGroups.map(x => x.id === g.id ? { ...x, tracks: x.tracks.map(y => y.id === t.id ? { ...y, title: v } : y) } : x) })); },
|
||
hasFlag: !!fl, flagLabel: fl ? fl[0] : '', flagColor: fl ? fl[1] : '', flagBg: fl ? fl[2] : '', flagIcon: fl ? fl[3] : '', note: t.note, hasNote: !!t.note,
|
||
playIcon: pl ? 'pause' : 'play_arrow', playBg: pl ? 'var(--accent)' : 'var(--surface3)', playC: pl ? '#fff' : 'var(--text)', onPlay: () => this.setState({ qPlay: pl ? null : t.id, playing: false }),
|
||
segs: [{ t: '…/', c: 'var(--text3)' }, ...qp.parts.slice(0, -1).map((x, i) => ({ t: x + '/', c: (i === 0 ? qp.artEx : qp.albEx) ? 'var(--text2)' : 'var(--accent2)' })), { t: qp.parts[qp.parts.length - 1], c: 'var(--text)' }],
|
||
onApprove: () => this.approve([t.id]), onReject: () => this.setState({ dlg: 'reject', rejectIds: [t.id] }) };
|
||
});
|
||
const qp0 = this.qPath(g, g.tracks[0]);
|
||
const tree = [{ pad: '0px', icon: 'hard_drive', t: 'NAS — музыка · /volume1/music', c: 'var(--text3)', note: '' }];
|
||
qp0.parts.slice(0, -1).forEach((x, i) => { const ex = i === 0 ? qp0.artEx : qp0.albEx; tree.push({ pad: (i + 1) * 20 + 'px', icon: ex ? 'folder' : 'create_new_folder', t: x + '/', c: ex ? 'var(--text2)' : 'var(--accent2)', note: ex ? 'уже есть' : 'будет создана' }); });
|
||
g.tracks.slice(0, 4).forEach(t => { const p = this.qPath(g, t).parts; tree.push({ pad: qp0.parts.length * 20 + 'px', icon: 'music_note', t: p[p.length - 1], c: 'var(--text)', note: '' }); });
|
||
if (g.tracks.length > 4) tree.push({ pad: qp0.parts.length * 20 + 'px', icon: 'more_horiz', t: 'ещё ' + (g.tracks.length - 4), c: 'var(--text3)', note: '' });
|
||
const isSel = cur && cur.id === g.id;
|
||
return { id: g.id, artist: g.artist, album: g.album, year: g.year, aw: 'calc(' + Math.max(3, g.artist.length + 1) + 'ch + 20px)', alw: 'calc(' + Math.max(3, g.album.length + 1) + 'ch + 20px)', cover: `radial-gradient(circle at 28% 22%, rgba(255,255,255,.3), rgba(255,255,255,0) 55%), linear-gradient(140deg, ${g.c1}, ${g.c2})`, by: g.by, when: g.when, note: g.note, quality: g.fmt + ' · ' + g.q,
|
||
count: g.tracks.length + ' ' + plural(g.tracks.length, ['трек', 'трека', 'треков']), sizeStr: size >= 1024 ? (size / 1024).toFixed(1) + ' ГБ' : size + ' МБ',
|
||
onArtist: gUpd(g.id, 'artist'), onAlbum: gUpd(g.id, 'album'), onYear: gUpd(g.id, 'year'), ...box(allOn),
|
||
onSelAll: () => this.setState(st => { const q = { ...st.qSel }; ids.forEach(id => { q[id] = !allOn; }); return { qSel: q }; }),
|
||
onApprove: () => this.approve(ids), onReject: () => this.setState({ dlg: 'reject', rejectIds: ids }),
|
||
tracks, tree, bg: isSel ? 'var(--accent-soft)' : 'transparent', border: isSel ? 'var(--accent)' : 'var(--line)', onPick: () => this.setState({ qGroup: g.id }) };
|
||
};
|
||
const mg = groups.map(mapG);
|
||
o.qq = {
|
||
grouped: (P.queueLayout || 'grouped') === 'grouped', split: P.queueLayout === 'split',
|
||
filters: [['all', 'Все', all.length], ['dup', 'Возможные дубли', all.filter(t => t.flag === 'dup').length], ['better', 'Лучшее качество', all.filter(t => t.flag === 'better').length]].map(([k, l, c]) => ({ label: l, count: c, onClick: () => this.setState({ qFilter: k }), bg: flt === k ? 'var(--text)' : 'transparent', color: flt === k ? 'var(--bg)' : 'var(--text)', border: flt === k ? 'var(--text)' : 'var(--line2)' })),
|
||
summary: all.length + ' ' + plural(all.length, ['трек', 'трека', 'треков']) + ' · ' + s.qGroups.length + ' ' + plural(s.qGroups.length, ['пачка', 'пачки', 'пачек']) + ' на рассмотрении',
|
||
groups: mg, cur: mg.find(g => g.id === (cur && cur.id)) || null, hasCur: !!cur, empty: !mg.length,
|
||
hasSel: sel.length > 0, selLabel: 'Выбрано: ' + sel.length,
|
||
onBulkApprove: () => this.approve(sel), onBulkReject: () => this.setState({ dlg: 'reject', rejectIds: sel }), onClearSel: () => this.setState({ qSel: {} })
|
||
};
|
||
}
|
||
if (pg === 'aset') {
|
||
const a = s.adm, upd = (k, v) => this.setState(st => ({ adm: { ...st.adm, [k]: v } }));
|
||
o.as = {
|
||
limit: a.limit, onLimit: e => upd('limit', Math.max(1, +e.target.value || 1)), perUser: tg(a.perUser), onPerUser: () => upd('perUser', !a.perUser), showPer: a.perUser,
|
||
perUserLimit: a.perUserLimit, onPerUserLimit: e => upd('perUserLimit', Math.max(1, +e.target.value || 1)), maxFile: a.maxFile, onMaxFile: e => upd('maxFile', Math.max(1, +e.target.value || 1)),
|
||
upgrade: tg(a.upgrade), onUpgrade: () => upd('upgrade', !a.upgrade), defSrc: a.defSrc, onDefSrc: e => upd('defSrc', e.target.value),
|
||
srcOpts: s.sources.filter(x => x.accepts || x.id === a.defSrc).map(x => ({ v: x.id, l: x.name })),
|
||
onSave: () => this.toastMsg('Настройки сохранены')
|
||
};
|
||
}
|
||
const f = s.f;
|
||
o.dlg = { create: s.dlg === 'create', temp: s.dlg === 'temp', reject: s.dlg === 'reject', any: !!s.dlg, close: () => this.setState({ dlg: null }), stop: e => e.stopPropagation() };
|
||
if (s.dlg === 'create') {
|
||
o.nu = { roles: [seg((s.nuRole || 'user') === 'user', 'Пользователь', () => this.setState({ nuRole: 'user' })), seg(s.nuRole === 'moderator', 'Модератор', () => this.setState({ nuRole: 'moderator' }))], canRole: isAdmin,
|
||
err: s.err.nuLogin || '', border: s.err.nuLogin ? 'var(--danger)' : 'var(--line2)',
|
||
onCreate: () => {
|
||
const l = (f.nuLogin || '').trim();
|
||
if (!/^[a-z0-9._-]{2,}$/.test(l)) return this.setState({ err: { nuLogin: 'Латиница, цифры и . _ - — минимум 2 символа' } });
|
||
if (s.users.some(u => u.login === l)) return this.setState({ err: { nuLogin: 'Такой логин уже есть' } });
|
||
const name = (f.nuName || '').trim() || l;
|
||
this.setState({ users: [...s.users, { id: Date.now(), name, login: l, role: isAdmin ? (s.nuRole || 'user') : 'user', status: 'mustchange', last: '—' }], dlg: 'temp', tempPw: genPw(), tempFor: name, err: {} });
|
||
} };
|
||
}
|
||
if (s.dlg === 'temp') o.tp = { pw: s.tempPw, who: s.tempFor, onCopy: () => { try { navigator.clipboard.writeText(s.tempPw); } catch (e) {} this.toastMsg('Временный пароль скопирован'); } };
|
||
if (s.dlg === 'reject') {
|
||
const n = s.rejectIds.length;
|
||
o.rj = { title: 'Отклонить ' + n + ' ' + plural(n, ['трек', 'трека', 'треков']), reason: f.reason || '',
|
||
quick: ['Низкое качество', 'Уже есть в библиотеке', 'Неполный альбом', 'Неверные метаданные'].map(r => ({ label: r, onClick: () => this.setState({ f: { ...f, reason: r } }), bg: f.reason === r ? 'var(--accent-soft)' : 'transparent', border: f.reason === r ? 'var(--accent)' : 'var(--line2)' })),
|
||
onConfirm: () => this.approve(s.rejectIds, 'reject') };
|
||
}
|
||
o.closeMenus = () => { if (s.uMenu) this.setState({ uMenu: null }); };
|
||
return o;
|
||
}
|
||
|
||
ambient(c3) {
|
||
if (!c3) return null;
|
||
const blob = (k, bg, size, anim, pos) => React.createElement('div', { key: k, style: { position: 'absolute', ...pos, width: size, height: size, borderRadius: '50%', background: `radial-gradient(circle, ${bg} 0%, transparent 62%)`, filter: 'blur(30px)', animation: anim, willChange: 'transform' } });
|
||
return React.createElement('div', { 'aria-hidden': true, style: { position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none', opacity: 'var(--amb-op)', zIndex: 0, animation: 'pipuHue 30s linear infinite' } }, [
|
||
blob('a', 'rgba(47,128,255,.75)', '70vmax', 'pipuDrift1 16s ease-in-out infinite alternate', { left: '-22vmax', top: '-28vmax' }),
|
||
blob('b', 'rgba(124,92,255,.6)', '60vmax', 'pipuDrift2 21s ease-in-out infinite alternate', { right: '-20vmax', top: '8vh' }),
|
||
blob('c', c3 + '99', '55vmax', 'pipuDrift3 26s ease-in-out infinite alternate', { left: '22vw', bottom: '-32vmax' })
|
||
]);
|
||
}
|
||
|
||
renderVals() {
|
||
const D = window.PIPU, s = this.state, P = this.props;
|
||
const theme = s.theme || P.theme || 'dark';
|
||
const base = { theme, shell: P.shell || 'classic', playerV: P.player || 'bar', waveVariant: P.waveVariant || 'orb', ambient: this.ambient(P.ambient === false ? null : (s.ready && D && s.playing ? D.ALB[D.TR[s.trackId].albumId].c1 : '#35D3FF')) };
|
||
if (!s.ready || !D) return { ...base, isAuth: false, isApp: false };
|
||
const { ART, ALB, TR, fmt, plural } = D;
|
||
const role = P.role || 'admin', isAdmin = role === 'admin', isMod = role === 'moderator';
|
||
const me = isAdmin ? { name: 'Алексей Морозов', login: 'admin', ini: 'АМ', role: 'Администратор' } : isMod ? { name: 'Илья Сафонов', login: 'ilya', ini: 'ИС', role: 'Модератор' } : { name: 'Анна Белова', login: 'anna', ini: 'АБ', role: 'Пользователь' };
|
||
const t = TR[s.trackId], al = ALB[t.albumId], ar = ART[t.artistId];
|
||
const eq = React.createElement('div', { style: { display: 'flex', gap: 2, alignItems: 'flex-end', height: 14 } }, [0, 1, 2].map(i => React.createElement('span', { key: i, style: { width: 3, height: 14, borderRadius: 2, background: 'var(--accent2)', transformOrigin: 'bottom', animation: `pipuEq ${0.55 + i * 0.17}s ease-in-out ${i * 0.1}s infinite alternate`, animationPlayState: s.playing ? 'running' : 'paused' } })));
|
||
const row = (id, i, list, label, extra = {}) => {
|
||
const tr = TR[id], isCur = s.trackId === id, liked = s.likes.includes(id);
|
||
return {
|
||
id, num: extra.num ?? i + 1, title: tr.title, artist: ART[tr.artistId].name, album: ALB[tr.albumId].title, dur: fmt(tr.dur), cover: ALB[tr.albumId].cover,
|
||
isCur, notCur: !isCur, eq, op: extra.op ?? 1, titleColor: isCur ? 'var(--accent2)' : 'var(--text)', likeColor: liked ? 'var(--accent2)' : 'var(--text3)', likeFill: liked ? F1 : F0,
|
||
bg: isCur ? 'var(--accent-soft)' : 'transparent',
|
||
onPlay: extra.onPlay || (() => this.playList(list, i, label)),
|
||
onLike: e => { e.stopPropagation(); this.toggleLike(id); },
|
||
onUndo: e => { e.stopPropagation(); this.toggleDislike(id); },
|
||
onArtist: e => { e.stopPropagation(); this.go('artist', { id: tr.artistId }); },
|
||
onAlbum: e => { e.stopPropagation(); this.go('album', { id: tr.albumId }); }
|
||
};
|
||
};
|
||
const seg = (on, label, onClick, extra = {}) => ({ label, onClick, bg: on ? 'var(--accent)' : 'transparent', color: on ? '#fff' : 'var(--text2)', ...extra });
|
||
const langs = [seg(s.lang === 'ru', 'RU', () => this.setState({ lang: 'ru' }), { full: 'Русский' }), seg(s.lang === 'en', 'EN', () => this.setState({ lang: 'en' }), { full: 'English' })];
|
||
const err = s.err || {}, errB = {};
|
||
['nlogin', 'np', 'np2', 'cur', 'sp', 'sp2'].forEach(k => { errB[k] = err[k] ? 'var(--danger)' : 'var(--line2)'; });
|
||
const fh = {}; ['login', 'pass', 'nlogin', 'np', 'np2', 'cur', 'sp', 'sp2', 'nuLogin', 'nuName', 'reason'].forEach(k => { fh[k] = this.fh(k); });
|
||
const common = { ...base, langs, f: s.f, fh, err, errB, onLogout: () => this.setState({ auth: 'login', playing: false, profileOpen: false, f: {}, err: {} }) };
|
||
|
||
if (s.auth) {
|
||
const adm = s.auth === 'setupAdmin';
|
||
return {
|
||
...common, isAuth: true, isApp: false,
|
||
auth: { login: s.auth === 'login', setup: s.auth !== 'login', setupAdmin: adm },
|
||
pwType: s.showPw ? 'text' : 'password', pwIcon: s.showPw ? 'visibility_off' : 'visibility', toggleShowPw: () => this.setState({ showPw: !s.showPw }),
|
||
onLogin: () => this.doLogin(), onLoginKey: e => { if (e.key === 'Enter') this.doLogin(); },
|
||
setupBadge: adm ? 'Первый вход администратора' : 'Пароль сброшен', setupBadgeIcon: adm ? 'shield_person' : 'lock_reset',
|
||
setupTitle: adm ? 'Смените стандартные логин и пароль' : 'Задайте новый пароль',
|
||
setupText: adm ? 'Без этого шага доступ к серверу закрыт. Стандартные данные admin / admin знает кто угодно.' : 'Администратор сбросил ваш пароль. Придумайте новый — временный больше не подойдёт.',
|
||
strength: this.strength(s.f.np), onSetup: () => this.doSetup()
|
||
};
|
||
}
|
||
|
||
const pg = { [s.page]: true }; pg.collection = ['history', 'likes', 'dislikes'].includes(s.page);
|
||
const navItem = (key, label, icon, extra = {}) => { const on = s.page === key; return { label, icon, count: '', badge: null, onClick: () => this.go(key), bg: on ? 'var(--accent-soft)' : 'transparent', color: on ? 'var(--text)' : 'var(--text2)', ic: on ? 'var(--accent2)' : 'var(--text3)', fill: on ? F1 : F0, ...extra }; };
|
||
const pending = s.qGroups.reduce((a, g) => a + g.tracks.length, 0);
|
||
const navAdm = [navItem('users', 'Пользователи', 'group')];
|
||
if (isAdmin) navAdm.push(navItem('sources', 'Хранилища', 'hard_drive'));
|
||
navAdm.push(navItem('library', 'Библиотека', 'monitoring'), navItem('upload', 'Предложить треки', 'upload'));
|
||
if (isAdmin) navAdm.push(navItem('queue', 'Очередь', 'inbox', { badge: pending || null }), navItem('aset', 'Настройки', 'tune'));
|
||
|
||
const actLbl = s.activity && D.ACT.find(a => a[0] === s.activity)[1], moodRec = s.mood && D.MOOD.find(m => m[0] === s.mood);
|
||
const ctxLabel = [actLbl, moodRec && moodRec[1]].filter(Boolean).join(' · ') || 'Под ваш вкус';
|
||
const waveIsPlaying = s.waveOn && s.playing;
|
||
const wc = moodRec ? { c1: moodRec[4], c2: moodRec[5], c3: moodRec[6] } : { c1: '#2F80FF', c2: '#7C5CFF', c3: '#35D3FF' };
|
||
if (waveIsPlaying) wc.c2 = al.c1;
|
||
const chip = (on, icon, label, onClick) => ({ label, icon, onClick, bg: on ? 'var(--accent)' : 'var(--surface2)', color: on ? '#fff' : 'var(--text)', border: on ? 'var(--accent)' : 'var(--line)', ic: on ? '#fff' : 'var(--text2)', shadow: on ? 'var(--glow)' : 'none' });
|
||
const srcLabel = s.waveOn ? (s.source.artistId ? 'Волна по исполнителю · ' + ART[s.source.artistId].name : 'Моя волна · ' + ctxLabel) : s.source.label;
|
||
const liked = s.likes.includes(t.id), dis = s.dislikes.includes(t.id);
|
||
const vol = s.muted ? 0 : s.vol;
|
||
|
||
const out = {
|
||
...common, isAuth: false, isApp: true, me, pg, showAdmin: isAdmin || isMod,
|
||
homeCenter: (P.homeLayout || 'center') === 'center', homeSplit: P.homeLayout === 'split',
|
||
isBar: (P.player || 'bar') === 'bar', isIsland: P.player === 'island',
|
||
nav: { home: () => this.go('home'), settings: () => this.go('settings') },
|
||
navTop: [navItem('home', 'Главная', 'home')],
|
||
navCol: [navItem('history', 'История', 'history'), navItem('likes', 'Лайки', 'favorite', { count: s.likes.length }), navItem('dislikes', 'Дизлайки', 'heart_broken', { count: s.dislikes.length })],
|
||
navAdm,
|
||
cur: { title: t.title, artist: ar.name, cover: al.cover, bpm: t.bpm, energy: t.energy, likeColor: liked ? 'var(--accent2)' : 'var(--text2)', likeFill: liked ? F1 : F0, disColor: dis ? 'var(--danger)' : 'var(--text2)', disFill: dis ? F1 : F0,
|
||
onLike: () => this.toggleLike(t.id), onDislike: () => this.toggleDislike(t.id), onArtist: () => this.go('artist', { id: ar.id }), onAlbum: () => this.go('album', { id: al.id }) },
|
||
waveOn: s.waveOn, waveIsPlaying, waveC: wc, waveIcon: waveIsPlaying ? 'pause' : 'play_arrow', ctxLabel, hasCtx: !!(s.activity || s.mood),
|
||
clearCtx: () => { this.setState({ activity: null, mood: null }); },
|
||
onWave: () => this.onWave(),
|
||
activities: D.ACT.map(a => chip(s.activity === a[0], a[2], a[1], () => this.setCtx('activity', a[0]))),
|
||
moods: D.MOOD.map(m => chip(s.mood === m[0], m[2], m[1], () => this.setCtx('mood', m[0]))),
|
||
playIcon: s.playing ? 'pause' : 'play_arrow', togglePlay: () => this.togglePlay(), next: () => this.next(), prev: () => this.prev(),
|
||
posStr: fmt(s.pos), durStr: fmt(t.dur), pct: (s.pos / t.dur * 100).toFixed(2) + '%',
|
||
onSeek: e => { const b = e.currentTarget.getBoundingClientRect(); this.setState({ pos: D.clamp((e.clientX - b.left) / b.width, 0, 0.999) * t.dur }); },
|
||
onVol: e => { const b = e.currentTarget.getBoundingClientRect(); this.setState({ vol: D.clamp((e.clientX - b.left) / b.width, 0, 1), muted: false }); },
|
||
volPct: vol * 100 + '%', volIcon: vol === 0 ? 'volume_off' : vol < 0.5 ? 'volume_down' : 'volume_up', toggleMute: () => this.setState({ muted: !s.muted }),
|
||
srcLabel, srcIcon: s.waveOn ? 'graphic_eq' : 'album', srcColor: s.waveOn ? 'var(--accent2)' : 'var(--text3)',
|
||
queueOpen: s.queueOpen, toggleQueue: () => this.setState({ queueOpen: !s.queueOpen, profileOpen: false }), queueColor: s.queueOpen ? 'var(--accent2)' : 'var(--text2)',
|
||
drawerBottom: (P.player || 'bar') === 'bar' ? '100px' : '8px', toastBottom: (P.player || 'bar') === 'bar' ? '112px' : '120px',
|
||
queueRows: s.queue.slice(s.qi, s.qi + 40).map((id, k) => row(id, k, s.queue, '', { onPlay: () => this.setTrack(s.qi + k, s.queue) })),
|
||
profileOpen: s.profileOpen, toggleProfile: () => this.setState({ profileOpen: !s.profileOpen, queueOpen: false }),
|
||
toggleTheme: () => this.setState({ theme: theme === 'dark' ? 'light' : 'dark' }), themeIcon: theme === 'dark' ? 'light_mode' : 'dark_mode', themeLabel: theme === 'dark' ? 'Светлая тема' : 'Тёмная тема',
|
||
themes: [seg(theme === 'dark', 'Тёмная', () => this.setState({ theme: 'dark' })), seg(theme === 'light', 'Светлая', () => this.setState({ theme: 'light' }))],
|
||
crossfade: s.crossfade, onCrossfade: e => this.setState({ crossfade: +e.target.value }),
|
||
normT: { bg: s.normalize ? 'var(--accent)' : 'var(--line2)', x: s.normalize ? '21px' : '3px' }, toggleNormalize: () => this.setState({ normalize: !s.normalize }),
|
||
onChangePw: () => { const f = s.f; if (!f.cur) return this.setState({ err: { sp: 'Введите текущий пароль', cur: 1 } }); if ((f.sp || '').length < 8) return this.setState({ err: { sp: 'Новый пароль — минимум 8 символов', sp: 'Новый пароль — минимум 8 символов' } }); if (f.sp !== f.sp2) return this.setState({ err: { sp: 'Пароли не совпадают', sp2: 1 } }); this.setState({ f: { ...f, cur: '', sp: '', sp2: '' }, err: {} }); this.toastMsg('Пароль изменён. Другие сессии завершены'); },
|
||
toast: s.toast, mainRef: this.mainRef, searchRef: this.searchRef, q: s.q
|
||
};
|
||
|
||
// search dropdown
|
||
const sr = s.q.trim() ? D.search(s.q) : null, items = [];
|
||
const mk = (title, sub, img, act, extra = {}) => { const it = { title, sub, img, act: e => { e && e.preventDefault && e.preventDefault(); act(); this.setState({ sOpen: false }); this.searchRef.current && this.searchRef.current.blur(); }, ...extra }; items.push(it); return it; };
|
||
const sArtists = sr ? sr.artists.slice(0, 3).map(a => mk(a.name, 'Исполнитель · ' + a.genre, a.avatar, () => this.go('artist', { id: a.id }), { ini: ini(a.name) })) : [];
|
||
const sAlbums = sr ? sr.albums.slice(0, 3).map(a => mk(a.title, ART[a.artistId].name + ' · ' + a.year, a.cover, () => this.go('album', { id: a.id }))) : [];
|
||
const sTracks = sr ? sr.tracks.slice(0, 5).map(id => mk(TR[id].title, ART[TR[id].artistId].name, ALB[TR[id].albumId].cover, () => this.playList(sr.tracks, sr.tracks.indexOf(id), 'Поиск · ' + s.q), { dur: fmt(TR[id].dur) })) : [];
|
||
items.forEach((it, i) => { it.bg = i === s.sIdx ? 'var(--hover)' : 'transparent'; });
|
||
this._sItems = items;
|
||
Object.assign(out, {
|
||
showDrop: s.sOpen && !!s.q.trim(), sArtists, sAlbums, sTracks, sHas: { artists: sArtists.length > 0, albums: sAlbums.length > 0, tracks: sTracks.length > 0 }, sNone: !!sr && !items.length, sFixed: sr && sr.fixed,
|
||
sBorder: s.sOpen ? 'var(--accent)' : 'var(--line)',
|
||
onSearchInput: e => this.setState({ q: e.target.value, sOpen: true, sIdx: -1 }), onSearchFocus: () => this.setState({ sOpen: true, profileOpen: false }), onSearchBlur: () => this.setState({ sOpen: false }),
|
||
onSearchKey: e => {
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); this.setState(x => ({ sIdx: Math.min(items.length - 1, x.sIdx + 1) })); }
|
||
else if (e.key === 'ArrowUp') { e.preventDefault(); this.setState(x => ({ sIdx: Math.max(-1, x.sIdx - 1) })); }
|
||
else if (e.key === 'Enter') { if (s.sIdx >= 0 && items[s.sIdx]) items[s.sIdx].act(); else this.openSearch(); }
|
||
else if (e.key === 'Escape') { e.target.blur(); }
|
||
},
|
||
openSearch: e => { e && e.preventDefault(); this.openSearch(); }
|
||
});
|
||
|
||
if (pg.search) {
|
||
const q = s.params.q || '', r = D.search(q), tab = s.searchTab;
|
||
const tabs = [['all', 'Всё', ''], ['artists', 'Исполнители', r.artists.length], ['albums', 'Альбомы', r.albums.length], ['tracks', 'Треки', r.tracks.length]].map(([k, l, c]) => ({ label: l, count: c, onClick: () => this.setState({ searchTab: k }), bg: tab === k ? 'var(--text)' : 'transparent', color: tab === k ? 'var(--bg)' : 'var(--text)', border: tab === k ? 'var(--text)' : 'var(--line2)' }));
|
||
out.sp = {
|
||
q, fixed: r.fixed, tabs,
|
||
showArtists: (tab === 'all' || tab === 'artists') && r.artists.length > 0, showAlbums: (tab === 'all' || tab === 'albums') && r.albums.length > 0, showTracks: (tab === 'all' || tab === 'tracks') && r.tracks.length > 0,
|
||
none: !r.artists.length && !r.albums.length && !r.tracks.length,
|
||
artists: r.artists.map(a => ({ name: a.name, sub: a.genre, img: a.avatar, ini: ini(a.name), onClick: () => this.go('artist', { id: a.id }) })),
|
||
albums: r.albums.map(a => ({ title: a.title, sub: ART[a.artistId].name + ' · ' + a.year, img: a.cover, onClick: () => this.go('album', { id: a.id }) })),
|
||
tracks: r.tracks.slice(0, tab === 'tracks' ? 50 : 8).map((id, i) => row(id, i, r.tracks, 'Поиск · ' + q))
|
||
};
|
||
}
|
||
if (pg.artist) {
|
||
const A = ART[s.params.id] || ar, top = [...A.tracks].sort((a, b) => TR[b].pop - TR[a].pop).slice(0, 6);
|
||
out.ap = {
|
||
name: A.name, genre: A.genre, img: A.avatar, banner: A.banner, ini: ini(A.name), meta: A.albums.length + ' ' + plural(A.albums.length, ['альбом', 'альбома', 'альбомов']) + ' · ' + A.tracks.length + ' ' + plural(A.tracks.length, ['трек', 'трека', 'треков']),
|
||
onWave: () => { this.startWave(A.id); this.toastMsg('Волна по исполнителю ' + A.name); }, onPlay: () => this.playList(top, 0, 'Исполнитель · ' + A.name),
|
||
onShuffle: () => { const l = [...A.tracks].sort(() => Math.random() - 0.5); this.playList(l, 0, 'Исполнитель · ' + A.name); },
|
||
tracks: top.map((id, i) => row(id, i, top, 'Исполнитель · ' + A.name)),
|
||
albums: A.albums.map(id => ({ title: ALB[id].title, sub: ALB[id].year + ' · Альбом', img: ALB[id].cover, onClick: () => this.go('album', { id }) }))
|
||
};
|
||
}
|
||
if (pg.album) {
|
||
const B = ALB[s.params.id] || al, A = ART[B.artistId], tot = B.tracks.reduce((x, id) => x + TR[id].dur, 0);
|
||
const isThis = !s.waveOn && B.tracks.includes(s.trackId) && s.source.label === 'Альбом · ' + B.title;
|
||
out.alp = {
|
||
title: B.title, cover: B.cover, artist: A.name, artistImg: A.avatar, year: B.year, tint: `linear-gradient(180deg, ${B.c1}66, transparent)`,
|
||
meta: B.tracks.length + ' ' + plural(B.tracks.length, ['трек', 'трека', 'треков']) + ', ' + Math.round(tot / 60) + ' мин',
|
||
playIcon: isThis && s.playing ? 'pause' : 'play_arrow',
|
||
onPlay: () => isThis ? this.togglePlay() : this.playList(B.tracks, 0, 'Альбом · ' + B.title),
|
||
onShuffle: () => { const l = [...B.tracks].sort(() => Math.random() - 0.5); this.playList(l, 0, 'Альбом · ' + B.title); },
|
||
onArtist: () => this.go('artist', { id: A.id }),
|
||
tracks: B.tracks.map((id, i) => row(id, i, B.tracks, 'Альбом · ' + B.title))
|
||
};
|
||
}
|
||
if (pg.collection) {
|
||
const tabs = [['history', 'История'], ['likes', 'Лайки'], ['dislikes', 'Дизлайки']].map(([k, l]) => ({ label: l, onClick: () => this.go(k), color: s.page === k ? 'var(--text)' : 'var(--text2)', border: s.page === k ? 'var(--accent)' : 'transparent' }));
|
||
let groups = [], c;
|
||
if (s.page === 'history') {
|
||
const list = s.history.map(h => h.id), by = [];
|
||
s.history.forEach((h, i) => { let g = by.find(x => x.day === h.day); if (!g) { g = { day: h.day, rows: [] }; by.push(g); } g.rows.push(row(h.id, i, list, 'История', { num: h.time })); });
|
||
groups = by;
|
||
c = { title: 'История', icon: 'history', tile: 'linear-gradient(140deg,#35D3FF,#2F80FF 60%,#1B3FA8)', sub: 'Всё, что вы слушали, по дням. Нажмите на трек, чтобы включить.', canPlay: false, emptyText: 'Здесь появятся прослушанные треки' };
|
||
} else if (s.page === 'likes') {
|
||
groups = [{ day: '', rows: s.likes.map((id, i) => row(id, i, s.likes, 'Лайки')) }];
|
||
c = { title: 'Лайки', icon: 'favorite', tile: 'linear-gradient(140deg,#5E9BFF,#2F80FF 50%,#7C5CFF)', sub: s.likes.length + ' ' + plural(s.likes.length, ['трек', 'трека', 'треков']) + ' · влияют на «Мою волну» сильнее всего', canPlay: s.likes.length > 0, emptyText: 'Ставьте ♥ трекам, чтобы собрать их здесь',
|
||
onPlay: () => this.playList(s.likes, 0, 'Лайки'), onShuffle: () => this.playList([...s.likes].sort(() => Math.random() - 0.5), 0, 'Лайки · вперемешку') };
|
||
} else {
|
||
groups = [{ day: '', rows: s.dislikes.map((id, i) => row(id, i, s.dislikes, 'Дизлайки', { op: 0.6 })) }];
|
||
c = { title: 'Дизлайки', icon: 'heart_broken', tile: 'linear-gradient(140deg,#4A5572,#1A2236)', sub: 'Эти треки не попадают в волну и рекомендации. Снимите дизлайк, если передумали.', canPlay: false, emptyText: 'Нет скрытых треков' };
|
||
}
|
||
out.col = { ...c, tabs, groups: groups.filter(g => g.rows.length), empty: !groups.some(g => g.rows.length), isDis: s.page === 'dislikes', notDis: s.page !== 'dislikes' };
|
||
}
|
||
if (this.adminVals) Object.assign(out, this.adminVals({ D, s, P, isAdmin, isMod, seg, fh, plural, fmt, eq }));
|
||
return out;
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|