poke/html/account-me.ejs
2025-09-13 13:48:53 +02:00

501 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Poke | Subscriptions</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link href="/css/yt-ukraine.svg?v=6" rel="icon">
<link href="/css/app.main.css?v=44600" rel="stylesheet">
<style>
:root{
--bg1:#231638; --bg2:#2b160e; --bg3:#09250e; --bg4:#0f132b;
--surface:#101014; /* surfaces behind cards */
--card:#15151a;
--ink:#ffffff;
--muted:#b7b7b7;
--line:#ffffff14;
--accent:#66ccff;
--ok:#6fff9b; --danger:#ff5b6e;
--chip:#111; --chipText:#eee;
--radius:14px;
--focus:#93c5fd;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
color:var(--ink);
font-family: system-ui,-apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, "Helvetica Neue", Arial, "Apple Color Emoji","Segoe UI Emoji";
background-image: radial-gradient(circle,var(--bg1),var(--bg2),var(--bg3),var(--bg4));
background-size: 400% 400%;
animation: bg-pan 64s ease infinite;
}
@media (prefers-reduced-motion: reduce){
body{animation: none}
}
@keyframes bg-pan{0%{background-position:0 50%}50%{background-position:100% 50%}100%{background-position:0 50%}}
a{color:inherit;text-decoration:none}
.wrap{
max-width:1100px;
margin:0 auto;
padding:20px;
}
header{
display:flex; align-items:center; gap:12px; flex-wrap:wrap;
}
h1{
margin:0;
font-family:"poketube flex", system-ui;
font-weight:800; letter-spacing:.3px;
}
/* UID: blurred-by-default, reveal on hover/focus.
Note: privacy-by-obscurity only; ID is still in markup. */
.uid{
--pad:6px;
display:inline-flex; align-items:center; gap:8px;
font-size:12px; color:var(--muted);
background: rgba(0,0,0,.25);
padding: var(--pad) 10px; border-radius:999px;
border:1px solid var(--line);
}
.uid .mask{
display:inline-block; filter: blur(7px);
transition: filter .16s ease;
}
.uid:hover .mask,
.uid:focus-within .mask{
filter: blur(0);
}
.uid button{
all:unset;
cursor:pointer;
padding:2px 6px; border-radius:6px;
background:#00000040; color:#fff; font-size:11px;
border:1px solid var(--line);
}
.uid button:focus-visible{outline:2px solid var(--focus); outline-offset:2px}
.toplink{float:right; margin-top:-6px}
@media (max-width:520px){.toplink{float:none; margin:0}.wrap{padding:16px}}
.bar{
margin-top:14px;
display:flex; flex-wrap:wrap; gap:10px; align-items:center;
}
.chip{
background:var(--chip); color:var(--chipText);
padding:8px 12px; border-radius:999px; font-size:12px;
border:1px solid var(--line);
}
.spacer{flex:1 1 auto}
.btn{
display:inline-flex; align-items:center; gap:8px;
background:var(--card); color:#fff;
padding:10px 12px; border-radius:10px;
border:1px solid var(--line);
transition: transform .2s ease, border-color .2s ease;
}
.btn:hover{ transform: translateY(-1px); border-color:#ffffff2e }
.btn--danger{ background:linear-gradient(135deg,#2a1216,#1b0f12); border-color:#3b151c }
.btn--accent{ background:linear-gradient(135deg,#0f1520,#122339) }
.search{
display:flex; align-items:center; gap:10px;
background:var(--card);
border:1px solid var(--line);
border-radius:12px; padding:8px 12px; min-width:220px;
}
.search input{all:unset; flex:1; font-size:14px}
.search button{
all:unset; cursor:pointer; font-size:12px; padding:4px 6px; border-radius:6px;
background:#00000040; border:1px solid var(--line)
}
.search button:focus-visible{outline:2px solid var(--focus); outline-offset:2px}
.group{
display:flex; align-items:center; gap:8px;
background:var(--surface); border:1px solid var(--line);
padding:6px; border-radius:12px;
}
.toggle{
all:unset; cursor:pointer; padding:8px 10px; border-radius:8px; font-size:13px;
background:#00000030; border:1px solid var(--line)
}
.toggle[aria-pressed="true"]{background:#00000055}
.grid{
margin-top:18px;
display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap:12px;
}
.list{ display:none } /* toggled via JS */
.card{
background:var(--card);
border:1px solid var(--line);
border-radius:var(--radius);
padding:14px;
display:flex; flex-direction:column; align-items:center; gap:10px;
transition: transform .18s ease, border-color .18s ease;
}
.card:hover{ transform: translateY(-2px); border-color:#ffffff26 }
.avatar{
width:88px; height:88px; border-radius:50%; object-fit:cover; background:#00000026;
}
.name{
font-weight:700; text-align:center; max-width:100%;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.row{display:flex; gap:8px; flex-wrap:wrap; justify-content:center}
.pill{
padding:8px 10px; border-radius:10px; font-size:13px;
background:#111; border:1px solid var(--line);
}
.pill--view{ background:linear-gradient(135deg,#111,#0e1a26) }
.pill--unsub{ background:linear-gradient(135deg,#1b1113,#251016); border-color:#3b151c }
.muted{ color:var(--muted); font-size:13px }
.empty{
margin:40px auto; max-width:560px; text-align:center;
background: rgba(0,0,0,.25);
border:1px dashed #ffffff2e; border-radius:16px; padding:20px;
}
.az-nav{
margin-top:12px; display:flex; flex-wrap:wrap; gap:6px;
}
.az-nav a{
display:inline-block; min-width:28px; text-align:center;
padding:6px 8px; border-radius:8px; border:1px solid var(--line);
background:#00000030; font-size:12px;
}
.letter{
margin:18px 0 8px;
font-family:"poketube flex", system-ui;
font-size:18px; letter-spacing:.3px;
opacity:.9;
}
/* ------------------------------
ACCESSIBILITY
------------------------------ */
.sr-only{
position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); border:0;
}
.focus-ring:focus-visible{ outline:2px solid var(--focus); outline-offset:2px }
</style>
</head>
<body>
<div class="wrap">
<div class="toplink">
<a class="btn" href="/api/get-channel-subs?ID=<%= encodeURIComponent(userid) %>">view json</a>
</div>
<header>
<h1>My subscriptions</h1>
<!-- Hover-to-reveal UID; click-to-reveal for keyboards/touch via JS; <details> fallback lives below for no-JS -->
<span class="uid" title="Keep this private">
<span>User:</span>
<span class="mask" aria-hidden="true"><%= userid %></span>
<button type="button" id="revealUID" class="focus-ring" aria-pressed="false" aria-controls="uidText">Reveal</button>
<span id="uidText" class="sr-only"><%= userid %></span>
</span>
</header>
<%
const subKeys = (userSubs && Object.keys(userSubs)) || [];
const subCount = subKeys.length;
const groups = {};
subKeys.forEach(function(channelID){
const c = userSubs[channelID] || {};
const nm = (c.channelName || '').trim();
const first = (nm[0] || '#').toUpperCase();
const key = /^[A-Z]$/.test(first) ? first : '#';
if(!groups[key]) groups[key] = [];
groups[key].push({ id: channelID, c: c });
});
const letters = Object.keys(groups).sort();
letters.forEach(function(L){
groups[L].sort(function(a,b){
const an = (a.c.channelName||'').toLowerCase();
const bn = (b.c.channelName||'').toLowerCase();
return an.localeCompare(bn,'en',{sensitivity:'base'});
});
});
%>
<!-- Toolbar: live features enhance with JS -->
<div class="bar" role="region" aria-label="Toolbar">
<div class="search" role="search">
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79L20 21.49 21.49 20zM9.5 14A4.5 4.5 0 1 1 14 9.5 4.505 4.505 0 0 1 9.5 14"/></svg>
<input id="search" class="focus-ring" type="text" placeholder="Search channels" aria-label="Search channels (client-side)">
<button id="clearSearch" title="Clear search" aria-label="Clear search">Clear</button>
</div>
<span id="count" class="chip" aria-live="polite"><%= subCount %> <%= subCount === 1 ? 'subscription' : 'subscriptions' %></span>
<span class="spacer"></span>
<div class="group" role="group" aria-label="Sort">
<button id="sortAZ" class="toggle focus-ring" aria-pressed="true" title="Sort A→Z">A→Z</button>
<button id="sortZA" class="toggle focus-ring" aria-pressed="false" title="Sort Z→A">Z→A</button>
</div>
</div>
<!-- No-JS fallback: show all groups as sections, already server-sorted -->
<div id="nojs-sections">
<% letters.forEach(function(L){ %>
<h2 id="sec-<%= L %>" class="letter"><%= L %></h2>
<div class="grid">
<% groups[L].forEach(function(entry){ const c = entry.c; const channelID = entry.id; %>
<div class="card" data-name="<%= (c.channelName||'').toLowerCase() %>">
<img class="avatar" loading="lazy" decoding="async"
src="<%= c.avatar %>"
alt="Avatar of <%= c.channelName %>"
>
<div class="name" title="<%= c.channelName %>"><%= c.channelName %></div>
<div class="row" role="group" aria-label="Actions">
<a class="pill pill--unsub focus-ring" href="/api/remove-channel-sub?ID=<%= encodeURIComponent(userid) %>&channelID=<%= channelID %>" data-unsub data-cid="<%= channelID %>" aria-label="Unsubscribe from <%= c.channelName %>">unsub</a>
<a class="pill pill--view focus-ring" href="/channel?id=<%= channelID %>" aria-label="View channel <%= c.channelName %>">view</a>
</div>
</div>
<% }) %>
</div>
<% }) %>
</div>
<!-- JS-enhanced container: we clone & control cards here for live search/sort/list toggle -->
<div id="app" style="display:none">
<div id="empty" class="empty" role="status" aria-live="polite" hidden>
<div class="muted">No subscriptions found.</div>
</div>
<div id="grid" class="grid" aria-label="Subscriptions (grid)"></div>
<div id="list" class="list" aria-label="Subscriptions (list)"></div>
</div>
<!-- Touch/no-JS fallback to reveal UID explicitly -->
<noscript>
<details style="margin-top:12px">
<summary class="muted">Reveal user ID (no JavaScript)</summary>
<code style="background:#00000033; padding:6px 8px; border-radius:6px; display:inline-block; margin-top:6px"><%= userid %></code>
</details>
<p class="muted" style="margin-top:12px">
Tip: Use the AZ jump links above or your browsers Find (Ctrl/Cmd + F) to locate a channel.
</p>
</noscript>
</div>
<script>
/* -----------------------------------------------------
Notes:
- We read cards from the server-rendered DOM, clone them
into a managed container, then hide the static sections.
----------------------------------------------------- */
(function(){
const $ = (s, root=document) => root.querySelector(s);
const $$ = (s, root=document) => Array.from(root.querySelectorAll(s));
const nojsSections = $('#nojs-sections');
const app = $('#app');
const grid = $('#grid');
const list = $('#list');
const searchInput = $('#search');
const clearBtn = $('#clearSearch');
const countBadge = $('#count');
const emptyState = $('#empty');
const btnAZ = $('#sortAZ');
const btnZA = $('#sortZA');
const btnGrid = $('#viewGrid');
const btnList = $('#viewList');
const revealBtn = $('#revealUID');
const uidMask = $('.uid .mask');
// Extract card data from the existing server markup (first section only is enough; all sections have .card)
const originalCards = $$('.card', nojsSections).map(card => {
const name = (card.getAttribute('data-name') || '').toLowerCase();
return { name, el: card };
});
if(originalCards.length === 0){
// Nothing to enhance; keep the server sections visible
return;
}
// Build managed copies (avoid moving originals to keep noscript usable if JS fails mid-way)
const managedCards = originalCards.map(({name, el})=>{
const clone = el.cloneNode(true);
// ensure links/buttons are keyboard-focusable
$$('.pill', clone).forEach(a => a.classList.add('focus-ring'));
return { name, el: clone };
});
// Hide static sections; show app containers
nojsSections.style.display = 'none';
app.style.display = '';
// Initial render into grid
renderGrid(managedCards);
// Event wiring
searchInput.addEventListener('input', onFilter);
clearBtn.addEventListener('click', () => { searchInput.value=''; onFilter(); searchInput.focus(); });
btnAZ.addEventListener('click', ()=>setSort('az'));
btnZA.addEventListener('click', ()=>setSort('za'));
btnGrid.addEventListener('click', ()=>setView('grid'));
btnList.addEventListener('click', ()=>setView('list'));
// Reveal UID for keyboards/touch: toggles blur programmatically
revealBtn.addEventListener('click', ()=>{
const pressed = revealBtn.getAttribute('aria-pressed') === 'true';
revealBtn.setAttribute('aria-pressed', String(!pressed));
uidMask.style.filter = pressed ? 'blur(7px)' : 'blur(0)';
revealBtn.textContent = pressed ? 'Reveal' : 'Hide';
});
// Delegate confirm() on unsub clicks
app.addEventListener('click', (e)=>{
const a = e.target.closest('a[data-unsub]');
if(!a) return;
const card = e.target.closest('.card');
const name = card ? card.querySelector('.name')?.textContent?.trim() : 'this channel';
if(!confirm('Unsubscribe from "' + name + '"?')) e.preventDefault();
});
// For browsers without native lazy-loading, manually warm cache
if (!('loading' in HTMLImageElement.prototype)) {
managedCards.forEach(({el})=>{
const img = el.querySelector('img');
if(img){ const i = new Image(); i.src = img.src; }
});
}
// ---------------- Functions ----------------
let currentView = 'grid'; // 'grid' | 'list'
let currentSort = 'az'; // 'az' | 'za'
let currentTerm = '';
function setView(kind){
currentView = kind;
btnGrid.setAttribute('aria-pressed', String(kind === 'grid'));
btnList.setAttribute('aria-pressed', String(kind === 'list'));
grid.style.display = (kind === 'grid') ? '' : 'none';
list.style.display = (kind === 'list') ? '' : 'none';
rerender();
}
function setSort(kind){
currentSort = kind;
btnAZ.setAttribute('aria-pressed', String(kind === 'az'));
btnZA.setAttribute('aria-pressed', String(kind === 'za'));
rerender();
}
function onFilter(){
currentTerm = (searchInput.value || '').trim().toLowerCase();
rerender();
}
function sortCards(arr){
const dir = currentSort === 'az' ? 1 : -1;
return arr.slice().sort((a,b)=> a.name.localeCompare(b.name,'en',{sensitivity:'base'}) * dir);
}
function filterCards(arr){
if(!currentTerm) return arr;
return arr.filter(c => c.name.includes(currentTerm));
}
function rerender(){
const filtered = filterCards(managedCards);
const sorted = sortCards(filtered);
updateCount(sorted.length);
if(sorted.length === 0){
emptyState.hidden = false;
grid.innerHTML = '';
list.innerHTML = '';
return;
} else {
emptyState.hidden = true;
}
if(currentView === 'grid') renderGrid(sorted);
else renderList(sorted);
}
function renderGrid(items){
grid.innerHTML = '';
const frag = document.createDocumentFragment();
items.forEach(({el}) => frag.appendChild(el.cloneNode(true)));
grid.appendChild(frag);
list.innerHTML = '';
}
function renderList(items){
list.innerHTML = '';
const frag = document.createDocumentFragment();
items.forEach(({el})=>{
// Convert card to a compact row for list view
const row = document.createElement('div');
row.className = 'card';
row.style.flexDirection = 'row';
row.style.alignItems = 'center';
row.style.justifyContent = 'space-between';
row.style.gap = '12px';
const left = document.createElement('div');
left.style.display = 'flex';
left.style.alignItems = 'center';
left.style.gap = '10px';
const img = el.querySelector('img').cloneNode(true);
img.style.width = '40px'; img.style.height = '40px'; img.style.borderRadius = '8px';
const nm = document.createElement('div');
nm.className = 'name';
nm.textContent = el.querySelector('.name')?.textContent || '';
left.appendChild(img);
left.appendChild(nm);
const actions = el.querySelector('.row').cloneNode(true);
row.appendChild(left);
row.appendChild(actions);
frag.appendChild(row);
});
list.appendChild(frag);
grid.innerHTML = '';
}
function updateCount(n){
countBadge.textContent = n + (n === 1 ? ' subscription' : ' subscriptions');
}
// Initial state
setView('grid');
setSort('az');
})();
</script>
</body>
</html>