- DD cycle data fetching
  - ShaiWatcher will now keep an updated loot table of the unique items in the DD each week
    The bot will **only** edit its message if already present, which should reduce message spam
  - Added command `/dd_update` to control the update behaviour. stop|resume|start [reason_text]
- Docsite changes
  - Added "ADMIN" tags to commands, signifying owner-only commands
  - Owner-only commands are now filtered under the "moderator" category
  - Added docs for `/dd_update`
- Logging
  - Added logging info for more verbose info relating to configuration and installation
		
	
			
		
			
				
	
	
		
			569 lines
		
	
	
		
			26 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
			
		
		
	
	
			569 lines
		
	
	
		
			26 KiB
		
	
	
	
		
			HTML
		
	
	
	
	
	
<!doctype html>
 | 
						||
<html lang="en">
 | 
						||
<head>
 | 
						||
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
 | 
						||
 | 
						||
<!-- Favicons (put files in /assets/docs/) -->
 | 
						||
<link rel="icon" type="image/png" href="/assets/docs/favicon.png">
 | 
						||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/docs/favicon-32x32.png">
 | 
						||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/docs/favicon-16x16.png">
 | 
						||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/docs/apple-touch-icon.png">
 | 
						||
<link rel="manifest" href="/assets/docs/site.webmanifest">
 | 
						||
 | 
						||
<title>__TITLE__</title>
 | 
						||
<style>
 | 
						||
  :root { --bg:#0b0f14; --panel:#121922; --muted:#6b7280; --fg:#e5e7eb; --accent:#60a5fa; --sticky-top: 8px; --header-h: 56px; }
 | 
						||
  * { box-sizing:border-box; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial; }
 | 
						||
  body { margin:0; background:var(--bg); color:var(--fg); }
 | 
						||
 | 
						||
  /* Header: 2/3 title, 1/3 actions */
 | 
						||
  header { padding:16px 20px; background:#0f172a; border-bottom:1px solid #223; position:sticky; top:0; z-index:3; }
 | 
						||
  .hdr { display:flex; align-items:center; gap:12px; }
 | 
						||
  .hdr .title { flex: 2 1 66%; font-size:20px; font-weight:600; }
 | 
						||
  .hdr .actions { flex: 1 1 34%; display:flex; justify-content:flex-end; gap:8px; }
 | 
						||
 | 
						||
  main { max-width:1200px; margin:20px auto; padding:0 16px 40px; }
 | 
						||
  .row { display:flex; gap:16px; flex-wrap:wrap; }
 | 
						||
  .col { flex:1 1 560px; min-width:320px; }
 | 
						||
  .panel { background:var(--panel); border:1px solid #1f2937; border-radius:12px; padding:16px; }
 | 
						||
 | 
						||
  .toolbar { margin-bottom:16px; position:sticky; top: var(--header-h); z-index:2; }
 | 
						||
  .search { width:100%; padding:10px 12px; border-radius:8px; border:1px solid #1f2937; background:#0b1220; color:var(--fg); }
 | 
						||
 | 
						||
  .list { margin-top:12px; display:flex; flex-direction:column; gap:10px; transition: filter .2s ease; }
 | 
						||
  .card { border:1px solid #233; border-radius:10px; padding:10px 12px; background:#0c1522; cursor:default; scroll-margin-top: calc(var(--sticky-top) + 12px); }
 | 
						||
  .name { font-weight:600; display:flex; align-items:center; gap:8px; }
 | 
						||
  .meta { font-size:12px; color:var(--muted); display:flex; gap:10px; flex-wrap:wrap; margin-top:4px; }
 | 
						||
  .pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid #2b4; }
 | 
						||
  .pill.mod { border-color:#ef4444; color:#fecaca; }
 | 
						||
  .pill.admin { border-color:#a78bfa; color:#e9d5ff; }
 | 
						||
  .pill.slash { border-color:#60a5fa; }
 | 
						||
  .pill.prefix { border-color:#f59e0b; }
 | 
						||
  .pill.hybrid { border-color:#34d399; }
 | 
						||
 | 
						||
  /* Usage block + copy button */
 | 
						||
  .usage{
 | 
						||
    --lh:1.6;                   /* line-height used to size the button */
 | 
						||
    line-height:var(--lh);
 | 
						||
    position:relative;
 | 
						||
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
 | 
						||
    font-size:12px;
 | 
						||
    background:#0a1220;
 | 
						||
    border:1px dashed #1f2937;
 | 
						||
    padding:6px 40px 6px 8px;    /* right padding to make room for the button */
 | 
						||
    border-radius:6px; margin-top:6px; word-break:break-word;
 | 
						||
  }
 | 
						||
  .usage > .copybtn{
 | 
						||
    position:absolute; right:0; top:0;
 | 
						||
    height: calc(var(--lh) * 1em + 12px);  /* one text line + vertical padding */
 | 
						||
    width:36px;
 | 
						||
    display:flex; align-items:center; justify-content:center;
 | 
						||
    background:#374151; border:0; color:#e5e7eb; border-radius:0 6px 0 6px;
 | 
						||
    cursor:pointer;
 | 
						||
  }
 | 
						||
  .usage > .copybtn:hover{ background:#4b5563; }
 | 
						||
  @media (max-width: 900px){ .usage > .copybtn{ width:auto; padding:0 10px; } }
 | 
						||
 | 
						||
  .help { margin-top:6px; color:#cbd5e1; }
 | 
						||
  .btn { padding:4px 8px; border:1px solid #334155; border-radius:8px; background:#0b1220; color:#e5e7eb; cursor:pointer; font-size:12px; }
 | 
						||
  .btn:hover { background:#0f172a; }
 | 
						||
  .btn-icon { width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; padding:0; }
 | 
						||
  .btn-row { display:flex; gap:8px; align-items:center; }
 | 
						||
 | 
						||
  .detailsbox { margin-top:12px; position: sticky; top: var(--sticky-top); min-height: 280px; z-index:1; transition: transform .25s ease; }
 | 
						||
 | 
						||
  /* Footer */
 | 
						||
  footer { margin-top:16px; color:var(--muted); font-size:12px; text-align:center; }
 | 
						||
  footer .line { margin:4px 0; }
 | 
						||
  footer a { color: var(--fg); text-decoration: none; border-bottom: 1px dotted #334155; }
 | 
						||
  footer a:hover { text-decoration: underline; }
 | 
						||
 | 
						||
  /* Discord drawer (right-side sheet) */
 | 
						||
  .drawer{ position:fixed; top:var(--header-h); right:0; width:clamp(360px,33vw,420px);
 | 
						||
           height:calc(100vh - var(--header-h) - 16px); background:var(--panel); border-left:1px solid #1f2937;
 | 
						||
           box-shadow:-16px 0 40px rgba(0,0,0,.45); transform:translateX(100%); transition:transform .25s ease;
 | 
						||
           z-index:6; border-top-left-radius:12px; border-bottom-left-radius:12px; overflow:hidden; }
 | 
						||
  .drawer.open{ transform:translateX(0); }
 | 
						||
  .drawer-close{ position:absolute; top:8px; right:8px; z-index:1; }
 | 
						||
  .drawer-backdrop{ position:fixed; inset:var(--header-h) 0 0 0; background:rgba(0,0,0,.35); display:none; z-index:5; }
 | 
						||
  .drawer-backdrop.open{ display:block; }
 | 
						||
 | 
						||
  /* Blur veil under toolbar */
 | 
						||
  .veil{ position:sticky; top:var(--header-h); height:16px; pointer-events:none; z-index:2;
 | 
						||
         backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
 | 
						||
         mask-image:linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1));
 | 
						||
         -webkit-mask-image:linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1)); }
 | 
						||
 | 
						||
  /* Mobile details as sheet */
 | 
						||
  @media (max-width: 900px){
 | 
						||
    .row{ display:block; }
 | 
						||
    .detailsbox{ position:fixed; right:0; top:var(--header-h); width:96vw; max-width:720px;
 | 
						||
                 height:calc(100vh - var(--header-h) - 16px); overflow:auto; transform:translateX(100%);
 | 
						||
                 box-shadow:-12px 0 30px rgba(0,0,0,.4); z-index:4; }
 | 
						||
    .detailsbox.open{ transform:translateX(0); }
 | 
						||
    #backdrop{ position:fixed; inset:var(--header-h) 0 0 0; background:rgba(0,0,0,.35); display:none; z-index:3; }
 | 
						||
    #backdrop.open{ display:block; }
 | 
						||
    #list.blur{ filter:blur(2px); }
 | 
						||
    #backdrop::before{ content:"‹"; position:absolute; left:10px; top:50%; transform:translateY(-50%);
 | 
						||
                       font-size:48px; line-height:1; color:#e5e7eb; opacity:.8; }
 | 
						||
  }
 | 
						||
 | 
						||
  /* Anchored copy popover + backdrop */
 | 
						||
  #copyBackdrop{ position:fixed; inset:0; display:none; z-index:20; background:transparent; }
 | 
						||
  #copyBackdrop.open{ display:block; }
 | 
						||
  #copyPopover{ position:fixed; display:none; z-index:21; background:var(--panel); border:1px solid #1f2937;
 | 
						||
                border-radius:10px; padding:10px; min-width: min(420px, 90vw);
 | 
						||
                box-shadow:0 18px 50px rgba(0,0,0,.45); }
 | 
						||
  #copyPopover.open{ display:block; }
 | 
						||
  #copyPopover .title{ font-weight:600; margin-bottom:6px; }
 | 
						||
  #copyPopover pre{ margin:6px 0; white-space:pre-wrap; word-break:break-word; background:#0a1220; border:1px dashed #1f2937; padding:8px; border-radius:6px; }
 | 
						||
 | 
						||
  /* Tiny toast */
 | 
						||
  #toast{ position:fixed; left:50%; bottom:28px; transform:translateX(-50%); background:#111827; color:#e5e7eb;
 | 
						||
          border:1px solid #1f2937; padding:6px 10px; border-radius:999px; font-size:12px; opacity:0; pointer-events:none; z-index:40; transition:opacity .18s ease; }
 | 
						||
  #toast.show{ opacity:1; }
 | 
						||
 | 
						||
  /* Bottom sheet (full command details) */
 | 
						||
  #fullDetailsBackdrop{
 | 
						||
    position:fixed; inset:var(--header-h) 0 0 0; display:none; z-index:12;
 | 
						||
    background:rgba(0,0,0,.35);
 | 
						||
  }
 | 
						||
  #fullDetailsBackdrop.open{ display:block; }
 | 
						||
 | 
						||
  #fullDetailsSheet{
 | 
						||
    position:fixed; left:50%; transform:translate(-50%, 100%);
 | 
						||
    bottom:0; width:min(1200px, 94vw); max-height:75vh; overflow:auto;
 | 
						||
    background:var(--panel); border:1px solid #1f2937;
 | 
						||
    border-radius:12px 12px 0 0; box-shadow:0 18px 50px rgba(0,0,0,.45);
 | 
						||
    z-index:13; transition:transform .25s ease;
 | 
						||
  }
 | 
						||
  #fullDetailsSheet.open{ transform:translate(-50%, 0); }
 | 
						||
 | 
						||
  #fullDetailsHead{
 | 
						||
    position:sticky; top:0; background:rgba(0,0,0,.35);
 | 
						||
    backdrop-filter: blur(6px);
 | 
						||
    -webkit-backdrop-filter: blur(6px);
 | 
						||
    display:flex; align-items:center; gap:8px;
 | 
						||
    padding:10px 12px; border-bottom:1px solid #1f2937;
 | 
						||
  }
 | 
						||
  #fullDetailsTitle{ font-weight:600; }
 | 
						||
  #fullDetailsBody{ padding:12px; }
 | 
						||
 | 
						||
  /* Small inline flag in footer */
 | 
						||
  .flag-emoji{
 | 
						||
    height: 1em;
 | 
						||
    width: auto;
 | 
						||
    vertical-align: -0.18em;
 | 
						||
    display: inline-block;
 | 
						||
    border-radius: 2px;
 | 
						||
  }
 | 
						||
</style>
 | 
						||
</head>
 | 
						||
<body>
 | 
						||
<header>
 | 
						||
  <div class="hdr">
 | 
						||
    <div class="title">__TITLE__</div>
 | 
						||
    <div class="actions">
 | 
						||
      <button id="openDiscord" class="btn btn-icon" title="Open Discord">💬</button>
 | 
						||
    </div>
 | 
						||
  </div>
 | 
						||
</header>
 | 
						||
 | 
						||
<main>
 | 
						||
  <!-- Toolbar -->
 | 
						||
  <div class="panel toolbar" id="toolbar">
 | 
						||
    <input id="q" class="search" placeholder="Search name/description…">
 | 
						||
    <div style="margin-top:8px; font-size:12px; color:var(--muted)">
 | 
						||
      Sections: <a href="#user">User</a> · <a href="#moderator">Moderator</a> · <a href="#all">All</a>
 | 
						||
      <span id="counts" style="margin-left:10px"></span>
 | 
						||
      <span id="alerts" style="margin-left:10px; color:#fbbf24"></span>
 | 
						||
    </div>
 | 
						||
  </div>
 | 
						||
 | 
						||
  <div class="veil" id="veil"></div>
 | 
						||
 | 
						||
  <div class="row">
 | 
						||
    <div class="col"><div id="list" class="list"></div></div>
 | 
						||
    <div class="col"><div class="panel detailsbox" id="details"></div></div>
 | 
						||
  </div>
 | 
						||
 | 
						||
  <footer id="footer">
 | 
						||
    <div class="line" id="copyright"></div>
 | 
						||
    <div class="line" id="statusline">Uptime: — · Version: v—</div>
 | 
						||
    <div class="line" id="coffee" style="display:__SUPPORT_VIS__">
 | 
						||
      <a id="supportLink" href="__SUPPORT_URL__" target="_blank" rel="noopener noreferrer">__SUPPORT_LABEL__</a>
 | 
						||
    </div>
 | 
						||
  </footer>
 | 
						||
 | 
						||
  <!-- Mobile details backdrop -->
 | 
						||
  <div id="backdrop"></div>
 | 
						||
 | 
						||
  <!-- Discord drawer -->
 | 
						||
  <div id="discordBackdrop" class="drawer-backdrop"></div>
 | 
						||
  <div id="discordSheet" class="drawer">
 | 
						||
    <button class="btn btn-icon drawer-close" title="Close">✕</button>
 | 
						||
    <iframe id="discordWidget"
 | 
						||
      src="https://discord.com/widget?id=1396826999095427253&theme=dark"
 | 
						||
      allowtransparency="true" frameborder="0"
 | 
						||
      sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
 | 
						||
      style="width:100%;height:100%;border:0"></iframe>
 | 
						||
  </div>
 | 
						||
 | 
						||
  <!-- Anchored copy popover -->
 | 
						||
  <div id="copyBackdrop"></div>
 | 
						||
  <div id="copyPopover" role="dialog" aria-modal="true">
 | 
						||
    <div class="title">Copy to clipboard</div>
 | 
						||
    <pre id="copyText">—</pre>
 | 
						||
    <div class="btn-row">
 | 
						||
      <button id="copyAction" class="btn">Copy</button>
 | 
						||
      <button id="copyClose" class="btn">Close</button>
 | 
						||
    </div>
 | 
						||
  </div>
 | 
						||
 | 
						||
  <!-- Bottom full-details sheet -->
 | 
						||
  <div id="fullDetailsBackdrop"></div>
 | 
						||
  <div id="fullDetailsSheet" role="dialog" aria-modal="true" aria-labelledby="fullDetailsTitle">
 | 
						||
    <div id="fullDetailsHead">
 | 
						||
      <div id="fullDetailsTitle">Command details</div>
 | 
						||
      <span style="flex:1"></span>
 | 
						||
      <button id="fullDetailsClose" class="btn btn-icon" title="Close">✕</button>
 | 
						||
    </div>
 | 
						||
    <div id="fullDetailsBody"></div>
 | 
						||
  </div>
 | 
						||
 | 
						||
  <!-- Tiny toast -->
 | 
						||
  <div id="toast" aria-live="polite">Copied!</div>
 | 
						||
</main>
 | 
						||
 | 
						||
<script>
 | 
						||
/* Sticky offsets */
 | 
						||
function computeStickyTop(){
 | 
						||
  const header=document.querySelector('header');
 | 
						||
  const toolbar=document.getElementById('toolbar');
 | 
						||
  const headerH=header?header.offsetHeight:0;
 | 
						||
  document.documentElement.style.setProperty('--header-h', headerH+'px');
 | 
						||
  const toolbarH=toolbar?toolbar.offsetHeight:0;
 | 
						||
  const stickyTop=headerH+toolbarH+8;
 | 
						||
  document.documentElement.style.setProperty('--sticky-top', stickyTop+'px');
 | 
						||
  const veil=document.getElementById('veil');
 | 
						||
  if(veil){ veil.style.top=headerH+'px'; veil.style.height='16px'; }
 | 
						||
}
 | 
						||
addEventListener('resize', computeStickyTop);
 | 
						||
addEventListener('load', computeStickyTop);
 | 
						||
 | 
						||
/* Error surface */
 | 
						||
addEventListener('error', e=>{ const el=document.getElementById('alerts'); if(el) el.textContent='JS error: '+(e?.message||''); });
 | 
						||
addEventListener('unhandledrejection', e=>{ const el=document.getElementById('alerts'); const msg=e&&(e.reason&&(e.reason.message||e.reason)||e); if(el) el.textContent='Promise error: '+msg; });
 | 
						||
 | 
						||
/* Mini MD (fallback when only .md exists) */
 | 
						||
function renderMD(src){ if(!src) return ""; let s=String(src).replace(/\r\n/g,"\n");
 | 
						||
 s=s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
 | 
						||
 s=s.replace(/```([\s\S]*?)```/g,(m,p1)=>"<pre><code>"+p1+"</code></pre>");
 | 
						||
 s=s.replace(/^###\s+(.*)$/gm,"<h3>$1</h3>");
 | 
						||
 s=s.replace(/^##\s+(.*)$/gm,"<h2>$1</h2>");
 | 
						||
 s=s.replace(/^#\s+(.*)$/gm,"<h1>$1</h1>");
 | 
						||
 s=s.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>");
 | 
						||
 s=s.replace(/\*(.+?)\*/g,"<em>$1</em>");
 | 
						||
 s=s.replace(/`([^`]+?)`/g,"<code>$1</code>");
 | 
						||
 s=s.replace(/^(\s*)-\s+(.+)$/gm,"$1<li>$2</li>");
 | 
						||
 s=s.replace(/(<li>.*<\/li>\n?)+/g,m=>"<ul>"+m+"</ul>");
 | 
						||
 s=s.replace(/^(?!<h\d|<ul>|<pre>|<li>)([^\n][^\n]*)$/gm,"<p>$1</p>");
 | 
						||
 return s; }
 | 
						||
 | 
						||
/* Link helpers */
 | 
						||
function rowAnchor(r){ const cog=(r.cog||'nocog').toLowerCase();
 | 
						||
 let base=(r.name||'').toLowerCase().replace(/^\//,'').replace(/\s+/g,'-').replace(/\//g,'-'); return `${cog}-${base}`; }
 | 
						||
function getFilterFromHash(){ const h=(location.hash||'#user').slice(1);
 | 
						||
 const parts=h.split('&').map(s=>s.trim()); const filt=parts.find(p=>p==='user'||p==='moderator'||p==='all')||'user';
 | 
						||
 let cmd=null; const cmdPart=parts.find(p=>p.startsWith('cmd=')); if(cmdPart) cmd=cmdPart.slice(4);
 | 
						||
 const sp=new URLSearchParams(location.search); const qcmd=sp.get('cmd'); if(!cmd&&qcmd) cmd=qcmd; return {filter:filt, cmd}; }
 | 
						||
function buildLink(anchor){ const url=new URL(location.href); url.searchParams.set('cmd',anchor); url.hash=location.hash||'#user'; return url.toString(); }
 | 
						||
function replaceURLFor(anchor){ try{ const url=new URL(location.href); url.searchParams.set('cmd',anchor); history.replaceState(null,'',url.toString()); }catch{} }
 | 
						||
 | 
						||
/* Copy helpers */
 | 
						||
async function copyText(s){ try{ await navigator.clipboard.writeText(s); return true; }catch{ return false; } }
 | 
						||
function showToast(msg='Copied!'){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'), 1100); }
 | 
						||
 | 
						||
const copyBackdrop=document.getElementById('copyBackdrop');
 | 
						||
const copyPopover=document.getElementById('copyPopover');
 | 
						||
const copyTextEl=document.getElementById('copyText');
 | 
						||
const copyAction=document.getElementById('copyAction');
 | 
						||
const copyClose=document.getElementById('copyClose');
 | 
						||
 | 
						||
function closeCopyPopover(){ copyBackdrop.classList.remove('open'); copyPopover.classList.remove('open'); }
 | 
						||
copyBackdrop.addEventListener('click', closeCopyPopover);
 | 
						||
copyClose.addEventListener('click', closeCopyPopover);
 | 
						||
 | 
						||
function openCopyPopover(text, anchorEl){
 | 
						||
  copyTextEl.textContent=text;
 | 
						||
  copyAction.onclick=async ()=>{ const ok=await copyText(text); if(ok){ closeCopyPopover(); showToast(); } };
 | 
						||
 | 
						||
  // Position: prefer above; keep inside viewport
 | 
						||
  const r=anchorEl.getBoundingClientRect();
 | 
						||
  const pad=8, vw=innerWidth, vh=innerHeight;
 | 
						||
  copyPopover.style.visibility='hidden'; copyPopover.classList.add('open'); // measure
 | 
						||
  const pw=copyPopover.offsetWidth, ph=copyPopover.offsetHeight;
 | 
						||
  let left = Math.min(Math.max(pad, r.right - pw), vw - pw - pad);
 | 
						||
  let top  = r.top - ph - pad;
 | 
						||
  if(top < (pad + 4)) top = Math.min(vh - ph - pad, r.bottom + pad);
 | 
						||
  copyPopover.style.left = `${left}px`;
 | 
						||
  copyPopover.style.top  = `${top}px`;
 | 
						||
  copyPopover.style.visibility='visible';
 | 
						||
  copyBackdrop.classList.add('open');
 | 
						||
}
 | 
						||
 | 
						||
/* Bottom sheet */
 | 
						||
const fdb = document.getElementById('fullDetailsBackdrop');
 | 
						||
const fds = document.getElementById('fullDetailsSheet');
 | 
						||
const fdbtn = document.getElementById('fullDetailsClose');
 | 
						||
const fdbody = document.getElementById('fullDetailsBody');
 | 
						||
const fdtitle = document.getElementById('fullDetailsTitle');
 | 
						||
function openFullDetails(title, html){
 | 
						||
  fdtitle.textContent = title || 'Command details';
 | 
						||
  fdbody.innerHTML = html || '<p>No extra details.</p>';
 | 
						||
  fdb.classList.add('open');
 | 
						||
  fds.classList.add('open');
 | 
						||
}
 | 
						||
function closeFullDetails(){ fdb.classList.remove('open'); fds.classList.remove('open'); }
 | 
						||
fdb?.addEventListener('click', closeFullDetails);
 | 
						||
fdbtn?.addEventListener('click', closeFullDetails);
 | 
						||
 | 
						||
/* App */
 | 
						||
(function(){
 | 
						||
  if(!location.hash) location.hash='#user';
 | 
						||
  let data=(window.__DATA__||null);
 | 
						||
 | 
						||
  const listEl=document.getElementById('list');
 | 
						||
  const qEl=document.getElementById('q');
 | 
						||
  const countsEl=document.getElementById('counts');
 | 
						||
  const detailsEl=document.getElementById('details');
 | 
						||
  const backdrop=document.getElementById('backdrop');
 | 
						||
 | 
						||
  function shownName(r){ return (r.display_name||r.name||'').replace(/^\//,''); }
 | 
						||
  function helpSansMod(r){ return (r.help||'').replace(/^\s*\[(MOD|ADMIN)\]\s*/i,''); }
 | 
						||
  function moduleSansPrefix(r){ const m=r.module||''; return m.replace(/^modules?\./,'').replace(/^discord\.ext\./,''); }
 | 
						||
 | 
						||
  async function shareFor(r){
 | 
						||
    const anchor=rowAnchor(r);
 | 
						||
    const url=buildLink(anchor);
 | 
						||
    const ok=await copyText(url);
 | 
						||
    if(ok) showToast('Link copied!');
 | 
						||
    else openCopyPopover(url, document.querySelector(`#card-${anchor} [data-share]`) || document.getElementById('shareDetails') || document.body);
 | 
						||
  }
 | 
						||
 | 
						||
  function usageBlockHTML(text){
 | 
						||
    if(!text) return '';
 | 
						||
    const esc=String(text).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
 | 
						||
    return `<div class="usage">${esc}<button class="btn btn-icon copybtn" title="Copy usage" data-copy="${esc}">📋</button></div>`;
 | 
						||
  }
 | 
						||
 | 
						||
  function wireUsageCopy(container){
 | 
						||
    container.querySelectorAll('[data-copy]').forEach(btn=>{
 | 
						||
      btn.addEventListener('click', async ev=>{
 | 
						||
        ev.stopPropagation();
 | 
						||
        const t=btn.getAttribute('data-copy').replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&');
 | 
						||
        const ok=await copyText(t);
 | 
						||
        if(ok) showToast();
 | 
						||
        else openCopyPopover(t, btn);
 | 
						||
      });
 | 
						||
    });
 | 
						||
  }
 | 
						||
 | 
						||
  function openDetails(r){
 | 
						||
    const anchor=rowAnchor(r);
 | 
						||
    replaceURLFor(anchor);
 | 
						||
    let html=`
 | 
						||
      <div class="name" style="margin-bottom:6px">
 | 
						||
        <span class="pill ${r.type}">${r.type}</span>
 | 
						||
        ${r.moderator_only?'<span class="pill mod">mod</span>':''}
 | 
						||
        ${r.admin_only?'<span class="pill admin">admin</span>':''}
 | 
						||
        <span>${shownName(r)}</span>
 | 
						||
        <span style="flex:1"></span>
 | 
						||
        <div class="btn-row">
 | 
						||
          <button class="btn btn-icon" title="Copy link" id="shareDetails">🔗</button>
 | 
						||
          <button class="btn" id="closeDetails" style="display:none">Close</button>
 | 
						||
        </div>
 | 
						||
      </div>
 | 
						||
      <div class="meta">
 | 
						||
        ${r.cog?`<span>cog: ${r.cog}</span>`:''}
 | 
						||
        ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
 | 
						||
        <span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
 | 
						||
      </div>
 | 
						||
      ${usageBlockHTML(r.usage_prefix)}
 | 
						||
      ${usageBlockHTML(r.usage_slash)}
 | 
						||
      ${helpSansMod(r)?`<div class="help">${helpSansMod(r)}</div>`:''}
 | 
						||
    `;
 | 
						||
 | 
						||
    // Brief/details HTML (or MD fallback)
 | 
						||
    if (r.brief_html) {
 | 
						||
      html += `<div class="md" style="margin-top:10px">${r.brief_html}</div>`;
 | 
						||
    } else if (r.details_html) {
 | 
						||
      html += `<div class="md" style="margin-top:10px">${r.details_html}</div>`;
 | 
						||
    } else if (r.details_md) {
 | 
						||
      html += `<div class="md" style="margin-top:10px">${renderMD(r.details_md)}</div>`;
 | 
						||
    } else {
 | 
						||
      html += `<div class="md" style="margin-top:10px"><p style="color:#9ca3af;font-size:14px;margin:0 0 6px 0">No custom documentation yet.</p></div>`;
 | 
						||
    }
 | 
						||
 | 
						||
    // "Open full details" when we have long content
 | 
						||
    const hasFull = !!(r.details_html || r.details_md);
 | 
						||
    if (hasFull) {
 | 
						||
      html += `<div class="btn-row" style="margin-top:10px">
 | 
						||
                 <button id="openFullDetails" class="btn">Open full details</button>
 | 
						||
               </div>`;
 | 
						||
    }
 | 
						||
 | 
						||
    detailsEl.innerHTML=html;
 | 
						||
 | 
						||
    const shareBtn=document.getElementById('shareDetails');
 | 
						||
    if(shareBtn) shareBtn.onclick=(e)=>{ e.stopPropagation(); shareFor(r); };
 | 
						||
    const fullBtn=document.getElementById('openFullDetails');
 | 
						||
    if(fullBtn){
 | 
						||
      fullBtn.onclick=(e)=>{
 | 
						||
        e.stopPropagation();
 | 
						||
        const longHtml = r.details_html ? r.details_html : renderMD(r.details_md || '');
 | 
						||
        openFullDetails(shownName(r), longHtml);
 | 
						||
      };
 | 
						||
    }
 | 
						||
    wireUsageCopy(detailsEl);
 | 
						||
 | 
						||
    // Mobile sheet open
 | 
						||
    if (matchMedia('(max-width: 900px)').matches){
 | 
						||
      detailsEl.classList.add('open'); listEl.classList.add('blur'); backdrop.classList.add('open');
 | 
						||
      const closeBtn=document.getElementById('closeDetails'); if(closeBtn) closeBtn.style.display='inline-block';
 | 
						||
      backdrop.onclick=closeDetails; if(closeBtn) closeBtn.onclick=closeDetails;
 | 
						||
    }
 | 
						||
  }
 | 
						||
  function closeDetails(){ detailsEl.classList.remove('open'); listEl.classList.remove('blur'); backdrop.classList.remove('open'); }
 | 
						||
 | 
						||
  function card(r){
 | 
						||
    const c=document.createElement('div'); const anchor=rowAnchor(r);
 | 
						||
    c.className='card'; c.id='card-'+anchor; c.dataset.anchor=anchor;
 | 
						||
    c.dataset.search=[shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase();
 | 
						||
 | 
						||
    const usageHTML=(r.type==='hybrid') ? `${usageBlockHTML(r.usage_prefix)}${usageBlockHTML(r.usage_slash)}` : `${usageBlockHTML(r.usage)}`;
 | 
						||
 | 
						||
    c.innerHTML=`
 | 
						||
      <div class="name">
 | 
						||
        <span class="pill ${r.type}">${r.type}</span>
 | 
						||
        ${r.moderator_only?'<span class="pill mod">mod</span>':''}
 | 
						||
        ${r.admin_only?'<span class="pill admin">admin</span>':''}
 | 
						||
        <span>${shownName(r)}</span>
 | 
						||
        <div class="btn-row">
 | 
						||
          <button class="btn btn-icon" title="Copy link" data-share="1">🔗</button>
 | 
						||
          <button class="btn" data-details="1">Details</button>
 | 
						||
        </div>
 | 
						||
      </div>
 | 
						||
      <div class="meta">
 | 
						||
        ${r.cog?`<span>cog: ${r.cog}</span>`:''}
 | 
						||
        ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
 | 
						||
        <span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
 | 
						||
      </div>
 | 
						||
      ${usageHTML}
 | 
						||
      ${helpSansMod(r)?`<div class="help">${helpSansMod(r)}</div>`:''}
 | 
						||
    `;
 | 
						||
    c.querySelector('[data-details]').addEventListener('click', ev=>{ ev.stopPropagation(); openDetails(r); });
 | 
						||
    c.querySelector('[data-share]').addEventListener('click', async ev=>{
 | 
						||
      ev.stopPropagation();
 | 
						||
      const url=buildLink(anchor);
 | 
						||
      const ok=await copyText(url);
 | 
						||
      if(ok) showToast('Link copied!');
 | 
						||
      else openCopyPopover(url, ev.currentTarget);
 | 
						||
    });
 | 
						||
    c.addEventListener('click', ()=>openDetails(r));
 | 
						||
    wireUsageCopy(c);
 | 
						||
    return c;
 | 
						||
  }
 | 
						||
 | 
						||
  function render(target, rows){ target.innerHTML=''; rows.forEach(r=>target.appendChild(card(r))); }
 | 
						||
 | 
						||
  function applyFilter(selectAnchorIfPresent=true){
 | 
						||
  if(!data) return;
 | 
						||
  const all=data.all||[], mods=(data.sections&&data.sections.moderator)||[], users=(data.sections&&data.sections.user)||[];
 | 
						||
  countsEl.textContent=`User: ${users.length} · Moderator: ${mods.length} · Total: ${all.length}`;
 | 
						||
 | 
						||
  const q=(qEl.value||'').toLowerCase();
 | 
						||
  const {filter, cmd}=getFilterFromHash();
 | 
						||
  const src=filter==='moderator'?mods:(filter==='all'?all:users);
 | 
						||
  const rows=!q?src:src.filter(r=>([shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase().includes(q)));
 | 
						||
  render(listEl, rows);
 | 
						||
 | 
						||
  const isMobile = matchMedia('(max-width: 900px)').matches;
 | 
						||
 | 
						||
  // Selection logic:
 | 
						||
  // - If URL has an anchor, prefer that (scroll to it)
 | 
						||
  // - Otherwise, ONLY auto-open first row on non-mobile
 | 
						||
  let sel = null;
 | 
						||
 | 
						||
  if (selectAnchorIfPresent && cmd) {
 | 
						||
    const hit = rows.find(r => rowAnchor(r) === cmd);
 | 
						||
    if (hit) sel = hit;
 | 
						||
    const cardEl = document.getElementById('card-'+cmd);
 | 
						||
    if (cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'});
 | 
						||
  } else if (!isMobile && rows.length) {
 | 
						||
    sel = rows[0];
 | 
						||
  }
 | 
						||
 | 
						||
  // Do not auto-open on mobile
 | 
						||
  if (sel && !isMobile) openDetails(sel);
 | 
						||
}
 | 
						||
 | 
						||
  async function boot(){
 | 
						||
    computeStickyTop();
 | 
						||
    try{
 | 
						||
      async function loadData(){
 | 
						||
        try{
 | 
						||
          const res = await fetch('/api/commands', { cache:'no-store' });
 | 
						||
          if(!res.ok) throw new Error('HTTP '+res.status);
 | 
						||
          data = await res.json();
 | 
						||
          return true;   // fetched fresh
 | 
						||
        }catch(e){
 | 
						||
          // fallback to inline bootstrap if fetch fails
 | 
						||
          if(window.__DATA__){ data = window.__DATA__; return false; }
 | 
						||
          throw e;
 | 
						||
        }
 | 
						||
      }
 | 
						||
 | 
						||
      addEventListener('hashchange', ()=>applyFilter(false));
 | 
						||
      qEl.addEventListener('input', ()=>applyFilter(false));
 | 
						||
 | 
						||
      await loadData();        // always refresh from backend
 | 
						||
      applyFilter(true);
 | 
						||
    }catch{ document.getElementById('alerts').textContent='Failed to load.'; }
 | 
						||
 | 
						||
    // Footer
 | 
						||
    const fromYear=2025, now=new Date();
 | 
						||
    const flagSvg='<img class="flag-emoji" alt="NO" src="/assets/docs/no.svg">';
 | 
						||
    document.getElementById('copyright').innerHTML=`© OokamiKunTV ${fromYear}–${now.getFullYear()} — Made in ${flagSvg} with ❤️`;
 | 
						||
    try{
 | 
						||
      const s=await fetch('/api/status',{cache:'no-store'});
 | 
						||
      if(s.ok){ const js=await s.json();
 | 
						||
        document.getElementById('statusline').textContent=`Uptime: ${fmtDuration(js.uptime_seconds||0)} · Version: v${js.version||'unknown'}`; }
 | 
						||
    }catch{}
 | 
						||
 | 
						||
    // Discord drawer
 | 
						||
    const ds=document.getElementById('discordSheet');
 | 
						||
    const db=document.getElementById('discordBackdrop');
 | 
						||
    const openBtn=document.getElementById('openDiscord');
 | 
						||
    const closeBtn=ds.querySelector('.drawer-close');
 | 
						||
    const openDiscord=()=>{ ds.classList.add('open'); db.classList.add('open'); };
 | 
						||
    const closeDiscord=()=>{ ds.classList.remove('open'); db.classList.remove('open'); };
 | 
						||
    if(openBtn) openBtn.onclick=openDiscord; if(closeBtn) closeBtn.onclick=closeDiscord; if(db) db.onclick=closeDiscord;
 | 
						||
  }
 | 
						||
 | 
						||
  function fmtDuration(s){ s=Math.max(0,Math.floor(+s||0));
 | 
						||
    const d=Math.floor(s/86400); s%=86400; const h=Math.floor(s/3600); s%=3600; const m=Math.floor(s/60); const sec=s%60;
 | 
						||
    const parts=[]; if(d) parts.push(d+'d'); if(h||d) parts.push(h+'h'); if(m||h||d) parts.push(m+'m'); parts.push(sec+'s'); return parts.join(' '); }
 | 
						||
 | 
						||
  setInterval(async ()=>{
 | 
						||
    try{
 | 
						||
      const gotFresh = await loadData();
 | 
						||
      if(gotFresh) applyFilter(false);
 | 
						||
    }catch{}
 | 
						||
  }, 30000);
 | 
						||
  boot();
 | 
						||
})();
 | 
						||
</script>
 | 
						||
</body>
 | 
						||
</html>
 |