poke/html/account-me.ejs
2025-09-13 13:58:41 +02:00

421 lines
15 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>
(function(){
const $ = (s, root=document) => root.querySelector(s);
const $$ = (s, root=document) => Array.from(root.querySelectorAll(s));
const nojsSections = $('#nojs-sections'); // static, server-rendered fallback
const app = $('#app'); // JS-enhanced container
const grid = $('#grid'); // managed grid
const searchInput = $('#search');
const clearBtn = $('#clearSearch');
const countBadge = $('#count');
const emptyState = $('#empty');
const revealBtn = $('#revealUID');
const uidMask = $('.uid .mask');
// Grab cards from server-rendered markup (keeps noscript intact if JS bails)
const sourceRoot = nojsSections || document;
const originalCards = $$('.card', sourceRoot).map(card => {
const name = (card.getAttribute('data-name') || '').toLowerCase().trim();
return { name, el: card };
});
if (originalCards.length === 0) {
// Nothing to enhance; keep the server sections visible
return;
}
// Build managed copies (dont move originals)
const managedCards = originalCards.map(({name, el})=>{
const clone = el.cloneNode(true);
$$('.pill', clone).forEach(a => a.classList.add('focus-ring')); // ensure focus styling
return { name, el: clone };
});
// Hide static sections; show enhanced UI
if (nojsSections) nojsSections.style.display = 'none';
app.style.display = '';
// Events
searchInput.addEventListener('input', onFilter);
clearBtn.addEventListener('click', () => { searchInput.value=''; onFilter(); searchInput.focus(); });
// UID reveal for keyboards/touch (CSS hover still works)
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';
});
// Confirm before unsubscribing (event delegation)
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();
});
// Warm image cache if native lazy-loading not supported
if (!('loading' in HTMLImageElement.prototype)) {
managedCards.forEach(({el})=>{
const img = el.querySelector('img');
if (img) { const i = new Image(); i.src = img.src; }
});
}
// ---------------- Logic ----------------
let currentTerm = '';
function onFilter(){
currentTerm = (searchInput.value || '').toLowerCase().trim();
rerender();
}
function sortAZ(arr){
return arr.slice().sort((a,b)=> a.name.localeCompare(b.name,'en',{sensitivity:'base'}));
}
function filterCards(arr){
if (!currentTerm) return arr;
return arr.filter(c => c.name.includes(currentTerm));
}
function rerender(){
const filtered = filterCards(managedCards);
const sorted = sortAZ(filtered);
updateCount(sorted.length);
if (sorted.length === 0){
emptyState.hidden = false;
grid.innerHTML = '';
return;
}
emptyState.hidden = true;
renderGrid(sorted);
}
function renderGrid(items){
grid.innerHTML = '';
const frag = document.createDocumentFragment();
items.forEach(({el}) => frag.appendChild(el.cloneNode(true)));
grid.appendChild(frag);
}
function updateCount(n){
countBadge.textContent = n + (n === 1 ? ' subscription' : ' subscriptions');
}
// Initial paint (sorted A→Z by default)
rerender();
})();
</script>
</body>
</html>