PHPIndex

This page lists files in the current directory. You can view content, get download/execute commands for Wget, Curl, or PowerShell, or filter the list using wildcards (e.g., `*.sh`).

.well-known
Drop
FreshRSS
admsnippets
cruddiy
downloads
kodbox
mdrone
pdf
pmnl3
speedtest
spreadsheet
swapshop
temp
test
tg-hof
uploads
wander
ECCM.html
wget 'https://lists2.roe3.org/ECCM.html'
View Content
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ethernet Cable Connection Manager</title>
<style>
  :root {
    --bg:#0f1115; --panel:#171a21; --ink:#e8eaf1; --muted:#a6adbb; --line:#262a33; --accent:#7cc4ff;
    --portGapFull:9px;  /* full-width (12 cols) */
    --portGapHalf:8px;  /* half-width (6 cols)  */
    --portW12:60px; --portW6:60px;
  }
  html,body{height:100%}
  body{margin:0;background:var(--bg);color:var(--ink);font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}
  *,*::before,*::after{box-sizing:border-box}
  header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;flex-wrap:wrap;background:linear-gradient(180deg,#12151b,#10131a)}
  header h1{font-size:18px;margin:0;font-weight:650}
  .badge{color:var(--muted);font-size:12px;border:1px solid var(--line);padding:2px 8px;border-radius:999px}

  .wrap{position:relative;display:grid;grid-template-columns:340px 1fr;gap:16px;height:calc(100% - 62px)}
  @media (max-width:1024px){.wrap{grid-template-columns:1fr;height:auto}}
  aside,main{padding:16px}
  aside{border-right:1px solid var(--line);background:var(--panel)}
  main{overflow:auto;position:relative}

  .card{background:#141821;border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:12px}
  .card h3{margin:0 0 8px 0;font-size:14px;font-weight:650}
  label{display:block;font-size:12px;color:var(--muted);margin:8px 0 4px}
  input[type=text],input[type=number],select{width:100%;max-width:100%;background:#0f131b;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;outline:none}
  button,.btn{background:#11151d;color:var(--ink);border:1px solid var(--line);padding:8px 10px;border-radius:8px;cursor:pointer;font-weight:600}
  button:hover{border-color:#2a2f3b}
  .btn-danger{border-color:#3b2222}
  .muted{color:var(--muted)} .small{font-size:12px}
  .mini{padding:4px 8px;font-size:12px;border-radius:6px}

  /* Devices layout (two-column grid) */
  .dev-rows{display:flex;flex-direction:column;gap:12px}
  .dev-row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
  .device-wrap{display:block;width:100%}
  .device-wrap.full{grid-column:1 / -1}

  .device{background:#131722;border:1px solid var(--line);border-radius:12px;padding:12px;position:relative;width:100%}
  .device-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}
  .device-title{font-size:15px;font-weight:650;display:flex;align-items:center;gap:8px}
  .swatch{width:12px;height:12px;border-radius:3px;border:1px solid #0006;display:inline-block}
  .device-actions{display:flex;gap:6px;flex-wrap:wrap}
  .meta{font-size:12px;color:var(--muted);margin-top:2px}
  .meta .inline-controls{margin-left:8px;display:inline-flex;gap:6px}

  /* Ports */
  .ports-rows{display:flex;flex-direction:column;gap:8px;margin-top:10px}
  .port-row{display:grid;gap:var(--portGapHalf)} /* default; overridden per row */
  .port{position:relative;border:1px solid var(--line);background:#0f141d;border-radius:10px;padding:8px 6px 6px;cursor:pointer;text-align:center;user-select:none;transition:background .12s}
  .port .num{font-size:12px}
  .port .alias{font-size:11px;margin-top:2px;min-height:1.2em}
  .port .peer{margin-top:4px;font-size:12px;font-weight:700;min-height:1.4em}
  .port .tip{position:absolute;top:6px;right:6px;font-size:10px;color:#7a8191}
  .port.selected{outline-offset:2px}

  /* Connections table */
  .grid-slim{width:100%;border-collapse:collapse;border:1px solid var(--line);border-radius:8px;overflow:hidden; table-layout: fixed;}
  .grid-slim th,.grid-slim td{font-size:13px;text-align:left;padding:8px 10px;border-bottom:1px solid var(--line); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
  .grid-slim th{background:#121722;color:var(--muted);font-weight:600}
  .conn-row.highlight td{background:#0f1522}

  /* Device name colouring in table */
  .conn-row td.deviceA .devName,
  .conn-row td.deviceB .devName { color:#fff; font-weight:400; }
  .conn-row.highlight td.deviceA .devName,
  .conn-row.highlight td.deviceB .devName { color:var(--devColor); font-weight:700; }

  /* Colour picker */
  .color-picker-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
  .swatch-lg{width:24px;height:24px;border-radius:6px;border:1px solid #0006;display:inline-block}
  .color-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;background:#0f141d;border:1px solid var(--line);border-radius:8px;cursor:pointer;font-weight:600;color:var(--ink)}
  .color-btn:hover{border-color:#2a2f3b}
  .palette-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center;z-index:50}
  .palette{background:#141821;border:1px solid var(--line);border-radius:12px;padding:12px;min-width:280px;max-width:90vw}
  .palette h4{margin:0 0 10px 0;font-size:14px}
  .palette-grid{display:grid;grid-template-columns:repeat(6, 36px);gap:8px;justify-content:center}
  .chip{width:36px;height:36px;border-radius:8px;border:1px solid rgba(0,0,0,.35);cursor:pointer}
  .palette .actions{display:flex;justify-content:flex-end;margin-top:12px}

  /* Layout modal */
  .modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center;z-index:60}
  .modal{background:#141821;border:1px solid var(--line);border-radius:12px;padding:14px;min-width:300px;max-width:90vw}
  .modal h4{margin:0 0 10px 0;font-size:14px}
  .modal .row{display:flex;gap:8px;align-items:center;margin:6px 0}
  .modal label{margin:0;color:var(--ink);font-size:13px}
  .modal select{background:#0f131b;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 10px}
  .modal .actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
</style>
</head>
<body>
<header>
  <h1>Ethernet Cable Connection Manager</h1>
  <span class="badge">Offline • LocalStorage • Profiles • JSON import/export • Print</span>
  <div style="margin-left:auto;display:flex;gap:8px"><button id="printSheet">Print layout</button></div>
</header>

<div class="wrap">
  <aside>
    <!-- Profiles -->
    <div class="card">
      <h3>Profiles</h3>
      <label for="profileSelect">Active profile</label>
      <select id="profileSelect"></select>

      <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
        <button id="newProfileBtn">New</button>
        <button id="renameProfileBtn">Rename</button>
        <button id="duplicateProfileBtn">Duplicate</button>
        <button id="deleteProfileBtn" class="btn-danger">Delete</button>

        <!-- Export/Import near profile ops -->
        <button id="exportProfileBtn" class="btn">Export</button>
        <button id="importProfileBtn" class="btn">Import</button>
        <input id="importProfileFile" type="file" accept=".json,application/json" style="display:none">
      </div>

      <div class="small muted" style="margin-top:6px">Profiles are separate layouts (e.g., different customers). Everything autosaves.</div>
    </div>

    <div class="card">
      <h3>Add device</h3>
      <label for="devName">Name</label>
      <input id="devName" type="text" placeholder="e.g. Core Switch A" />
      <div class="row">
        <div>
          <label for="devPorts">Ports</label>
          <input id="devPorts" type="number" inputmode="numeric" min="1" max="512" value="24" />
        </div>
      </div>
      <label>Colour</label>
      <div class="color-picker-row">
        <button type="button" class="color-btn" id="openPalette">Select colour</button>
        <span id="devColorPreview" class="swatch swatch-lg" title="Selected colour"></span>
      </div>
      <div style="margin-top:10px;display:flex;gap:8px">
        <button id="addBtn">Add device</button>
        <button id="clearAll" class="btn-danger" title="Delete everything">Clear all</button>
      </div>
      <div class="small" style="margin-top:6px">
        Click two free ports to connect • <strong>Unlink</strong> in Connections • <span class="muted">Alt-click</span> a port to set an alias
      </div>
    </div>

    <!-- Backup/restore all profiles -->
    <div class="card">
      <h3>Backup / Restore (all profiles)</h3>
      <div style="display:flex;gap:8px;flex-wrap:wrap">
        <button id="backupAllBtn" class="btn">Backup all</button>
        <button id="restoreAllBtn" class="btn">Restore all</button>
        <input id="restoreAllFile" type="file" accept=".json,application/json" style="display:none">
      </div>
      <div class="small muted" style="margin-top:6px">Saves or restores every profile on this device.</div>
    </div>

    <div class="card">
      <h3>Find connection</h3>
      <input id="searchBox" type="text" placeholder="Filter by device or port…" />
      <div class="small muted">Filters the table below.</div>
    </div>
  </aside>

  <main>
    <div class="card">
      <h3>Devices</h3>
      <div id="devRows" class="dev-rows"></div>
    </div>

    <div class="card">
      <h3>Connections</h3>
      <table class="grid-slim" id="connTable">
        <thead><tr><th>#</th><th>Device A</th><th>Port</th><th>Alias</th><th>⇄</th><th>Device B</th><th>Port</th><th>Alias</th><th></th></tr></thead>
        <tbody id="connBody"></tbody>
      </table>
      <div class="small muted">Click a row (or a connected port) to highlight both ends.</div>
    </div>
  </main>
</div>

<!-- Palette -->
<div class="palette-backdrop" id="paletteModal">
  <div class="palette" role="dialog" aria-modal="true" aria-labelledby="paletteTitle">
    <h4 id="paletteTitle">Select colour</h4>
    <div class="palette-grid" id="paletteGrid"></div>
    <div class="actions"><button type="button" id="paletteClose">Close</button></div>
  </div>
</div>

<!-- Layout modal -->
<div class="modal-backdrop" id="layoutModal">
  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="layoutTitle">
    <h4 id="layoutTitle">Device layout options</h4>
    <div class="row"><label style="min-width:110px">Device</label><span id="layoutDeviceName" class="small muted"></span></div>
    <div class="row">
      <label style="min-width:110px">Row width</label>
      <select id="layoutFullRow">
        <option value="auto">Auto (follow rules)</option>
        <option value="full">Force full row</option>
      </select>
    </div>
    <div class="row" id="optMidWrap">
      <label style="min-width:110px">13–24 ports</label>
      <select id="layoutMidWrap">
        <option value="balanced">Balanced (½ + ½)</option>
        <option value="twelve">12 + remainder</option>
      </select>
    </div>
    <div class="row" id="optSmallWrap">
      <label style="min-width:110px">≤12 ports</label>
      <select id="layoutSmallWrap">
        <option value="single">Single row</option>
        <option value="split">Split into 2 rows</option>
      </select>
    </div>
	<div class="row" id="optDualLink">
		<label style="min-width:110px">Dual link</label>
		<select id="layoutDualLink">
    <option value="off">Normal</option>
    <option value="on">Dual link</option>
  </select>
</div>

    <div class="actions">
      <button type="button" id="layoutCancel">Cancel</button>
      <button type="button" id="layoutSave">Save</button>
    </div>
  </div>
</div>

<script>
/* ========= PROFILES STORAGE ========= */
var STORE_KEY = 'ethcm_profiles_v1';
var OLD_SINGLE_KEY = 'ethcm_v12';
var defaultState = { devices: [], links: [], portAliases: {} };
function deepClone(o){ return JSON.parse(JSON.stringify(o)); }
function uid(){ return 'id_' + Math.random().toString(36).slice(2,10); }

var store = (function(){
  try{
    var raw = localStorage.getItem(STORE_KEY);
    if(raw){
      var parsed = JSON.parse(raw);
      if(parsed && parsed.current && parsed.profiles) return parsed;
    }
    var oldRaw = localStorage.getItem(OLD_SINGLE_KEY);
    if(oldRaw){
      var one = JSON.parse(oldRaw);
      normalizeState(one);
      return { current:'Migrated', profiles:{ 'Migrated': one } };
    }
  }catch(e){}
  return { current:'Default', profiles:{ 'Default': deepClone(defaultState) } };
})();
function normalizeState(st){
  st.devices = Array.isArray(st.devices) ? st.devices : [];
  st.links = Array.isArray(st.links) ? st.links : [];
  st.portAliases = st.portAliases || {};
  st.devices.forEach(function(d){
    if (d.forceFullRow === undefined) d.forceFullRow = false;
    if (d.midWrapMode === undefined) d.midWrapMode = 'balanced';
    if (d.smallWrap === undefined) d.smallWrap = false;  // <=12 split flag
	if (d.dualLink === undefined) d.dualLink = false;  // NEW
  });
}
function saveStore(){ try{ localStorage.setItem(STORE_KEY, JSON.stringify(store)); }catch(e){} }

/* Active profile reference */
var state = store.profiles[store.current];

/* ========= DOM HELPERS ========= */
function $(s){ return document.querySelector(s); }
function $all(s){ return Array.prototype.slice.call(document.querySelectorAll(s)); }
function el(tag, attrs){
  var node = document.createElement(tag); attrs = attrs||{};
  Object.keys(attrs).forEach(function(k){
    var v = attrs[k];
    if(k === 'dataset'){ Object.keys(v).forEach(function(dk){ node.dataset[dk] = v[dk]; }); }
    else if(k === 'class'){ node.className = v; }
    else if(k.indexOf('on') === 0 && typeof v === 'function'){ node.addEventListener(k.slice(2), v); }
    else { node.setAttribute(k, v); }
  });
  for(var i=2;i<arguments.length;i++){ var c = arguments[i]; if(c==null) continue; node.appendChild(c.nodeType ? c : document.createTextNode(c)); }
  return node;
}
function deviceById(id){ for(var i=0;i<state.devices.length;i++){ if(state.devices[i].id===id) return state.devices[i]; } return null; }
function indexById(id){ return state.devices.findIndex(function(d){return d.id===id;}); }
function linkForPort(deviceId, port, sub){
  for(var i=0;i<state.links.length;i++){
    var L = state.links[i];
    if((L.a.deviceId===deviceId && L.a.port===port && (L.a.sub||null)=== (sub||null)) ||
       (L.b.deviceId===deviceId && L.b.port===port && (L.b.sub||null)=== (sub||null))) return L;
  }
  return null;
}

function keyFor(deviceId, port, sub){
  return deviceId + ':' + port + ':' + (sub==null ? '' : sub);
}
function aliasFor(deviceId, port, sub){
  return state.portAliases[keyFor(deviceId,port,sub)] || '';
}
function getPeer(deviceId, port, sub){
  var L = linkForPort(deviceId, port, sub); if(!L) return null;
  if (L.a.deviceId===deviceId && L.a.port===port && (L.a.sub||null)===(sub||null)) return L.b;
  return L.a;
}


/* ========= COLOUR / SELECTION ========= */
function hexToRgb(hex){ var h = String(hex||'').replace('#','').trim(); if (h.length===3) h = h.split('').map(function(c){return c+c}).join(''); var n = parseInt(h,16); if (isNaN(n)||h.length!==6) return {r:15,g:20,b:29}; return { r:(n>>16)&255, g:(n>>8)&255, b:n&255 }; }
function bestTextColorFor(bgHex){ var c = hexToRgb(bgHex); var L = 0.2126*(c.r/255) + 0.7152*(c.g/255) + 0.0722*(c.b/255); return (L > 0.6) ? '#000' : '#fff'; }
function paintPortBase(node, bgHex){
  var bg = bgHex || '#0f141d';
  node.style.background = bg;
  var txt = bestTextColorFor(bg);
  node.style.color = txt;
  var alias = node.querySelector('.alias'); if (alias) alias.style.color = txt;
  var num   = node.querySelector('.num');   if (num)   num.style.color   = txt;
  var peer  = node.querySelector('.peer');  if (peer)  peer.style.color  = txt;
}
function clearSelectionOutlines(){ $all('.port.selected').forEach(function(n){ n.classList.remove('selected'); n.style.outline='none'; }); }
function outlineForSelection(node, thick){ var bg = node.style.background || '#0f141d'; var border = bestTextColorFor(bg); node.classList.add('selected'); node.style.outline = (thick?'5px':'3px')+' solid '+border; }

/* ========= UNIFORM PORT WIDTHS (two gaps) ========= */
function updateUniformPortWidth(){
  var host = document.getElementById('devRows'); if(!host) return;
  var GAP_COL = 12;  /* .dev-row column gap */
  var PAD_BRD = 26;  /* .device L/R padding+border */
  var gapFull = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--portGapFull')) || 9;
  var gapHalf = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--portGapHalf')) || 8;

  var Wrows = host.getBoundingClientRect().width || 800;

  var WfullInner = Wrows - PAD_BRD;                   // 12 cols -> 11 gaps
  var WhalfInner = ((Wrows - GAP_COL)/2) - PAD_BRD;   // 6 cols  -> 5 gaps

  var portW12 = (WfullInner - (11*gapFull)) / 12;
  var portW6  = (WhalfInner - (5*gapHalf)) / 6;

  portW12 = Math.max(40, portW12);
  portW6  = Math.max(40, portW6);

  document.documentElement.style.setProperty('--portW12', portW12 + 'px');
  document.documentElement.style.setProperty('--portW6',  portW6  + 'px');
}
var __pw_timer=null;
window.addEventListener('resize', function(){ clearTimeout(__pw_timer); __pw_timer=setTimeout(updateUniformPortWidth, 60); });

/* ========= LAYOUT RULES ========= */
function isFullWidthDevice(d){
  if (d.forceFullRow) return true;
  if ((d.ports||1) >= 13) return true;            // 13+ always full
  if ((d.ports||1) >= 7 && !d.smallWrap) return true; // 7–12 full only if NOT split
  return false;                                   // 1–6 or 7–12 split → half
}

function splitPortsIntoRows(n, dev){
  if (n >= 12){
    if (n >= 13 && n <= 24 && dev.midWrapMode === 'balanced'){
      var a = Math.ceil(n/2); return [Math.min(12,a), Math.min(12,n-a)];
    }
    var rows=[], rem=n; while(rem>0){ rows.push(Math.min(12, rem)); rem -= 12; } return rows;
  }
  if (n >= 7){
    if (dev.smallWrap){
      var a = Math.ceil(n/2);
      return [a, n - a];   // e.g., 9 → 5+4, 12 → 6+6
    }
    return [n];            // default single row
  }
  return [n];
}

/* ========= CONNECT / UNLINK ========= */
var pendingPort = null;
var highlightedLinkId = null;

function connectPorts(a,b){
  if (linkForPort(a.deviceId,a.port) || linkForPort(b.deviceId,b.port)) return false;
  var id = uid(); state.links.push({ id:id, a:a, b:b }); saveStore(); render(); highlightLink(id); return true;
}
function unlink(linkId){
  state.links = state.links.filter(function(L){ return L.id!==linkId; });
  if(highlightedLinkId===linkId) highlightedLinkId=null;
  saveStore(); render();
}

/* ========= RENDER DEVICES ========= */
function renderDevices(){
  var host = $('#devRows'); host.innerHTML='';
  if(!state.devices.length){ host.appendChild(el('div',{class:'muted'},'No devices yet — add one on the left.')); updateUniformPortWidth(); return; }

  var i=0;
  while(i < state.devices.length){
    var rowEl = el('div',{class:'dev-row'});
    var d = state.devices[i];
    if (isFullWidthDevice(d)){
      rowEl.appendChild(renderDeviceWrap(d, true)); i += 1;
    } else {
      rowEl.appendChild(renderDeviceWrap(d, false));
      if (i+1 < state.devices.length && !isFullWidthDevice(state.devices[i+1])){
        rowEl.appendChild(renderDeviceWrap(state.devices[i+1], false)); i += 2;
      } else {
        rowEl.appendChild(el('div',{class:'device-wrap'}, el('div',{class:'device', style:'visibility:hidden'}, ' '))); i += 1;
      }
    }
    host.appendChild(rowEl);
  }
  updateUniformPortWidth();
}

function renderDeviceWrap(d, fullWidth){
  var wrap = el('div',{class:'device-wrap ' + (fullWidth ? 'full' : ''), dataset:{id:d.id}});
  wrap.draggable = true;

  wrap.addEventListener('dragstart', function(e){ 
    wrap.classList.add('dragging'); 
    e.dataTransfer.setData('text/plain', d.id); 
    e.dataTransfer.effectAllowed='move'; 
  });
  wrap.addEventListener('dragend', function(){ wrap.classList.remove('dragging'); });
  wrap.addEventListener('dragover', function(e){ e.preventDefault(); e.dataTransfer.dropEffect='move'; });
  wrap.addEventListener('drop', function(e){
    e.preventDefault();
    var draggedId = e.dataTransfer.getData('text/plain'); if (!draggedId || draggedId === d.id) return;
    var from = indexById(draggedId), to = indexById(d.id); if (from<0 || to<0) return;
    var item = state.devices.splice(from,1)[0]; if (from < to) to -= 1; state.devices.splice(to,0,item); 
    saveStore(); render();
  });

  var head = el('div',{class:'device-head'},
    el('div',{class:'device-title'},
      el('span',{class:'swatch', style:'background:'+(d.color||'#888')}),
      document.createTextNode(d.name||'Unnamed')
    ),
    el('div',{class:'device-actions'},
      el('button',{title:'Layout options', onclick:function(ev){ ev.stopPropagation(); openLayoutModal(d.id); }}, 'Layout'),
      el('button',{title:'Edit', onclick:function(ev){
        ev.stopPropagation();
        var newName = prompt('Device name:', d.name||''); if(newName==null || !newName.trim()) return;
        var np = prompt('Number of ports (>=1):', String(d.ports||1)); if(np==null) return;
        changePorts(d, Math.max(1, Math.min(512, Number(np)||1)));
        d.name = newName.trim();
        var newColor = prompt('Hex colour (e.g. #E74C3C):', d.color||'#888'); if(newColor) d.color = newColor;
        saveStore(); render();
      }}, 'Edit'),
      el('button',{class:'btn-danger',title:'Delete', onclick:function(ev){
        ev.stopPropagation();
        if(!confirm('Delete "'+(d.name||'Unnamed')+'" and its links?')) return;
        state.links = state.links.filter(function(L){ 
          return String(L.a.deviceId)!==String(d.id) && String(L.b.deviceId)!==String(d.id); 
        });
        state.devices = state.devices.filter(function(x){ return String(x.id)!==String(d.id); });
        Object.keys(state.portAliases).forEach(function(k){ 
          if(k.split(':')[0]===String(d.id)) delete state.portAliases[k]; 
        });
        saveStore(); render();
      }}, 'Delete')
    )
  );

  var meta = el('div',{class:'meta'},
    document.createTextNode((d.ports||1)+' ports'),
    (function(){
      var c = el('span',{class:'inline-controls'},
        el('button',{class:'mini', title:'Add one port', onclick:function(e){ e.stopPropagation(); changePorts(d, (d.ports||1)+1); saveStore(); render(); }}, '+ Port'),
        el('button',{class:'mini', title:'Remove one port', onclick:function(e){ e.stopPropagation(); if ((d.ports||1) <= 1) return; changePorts(d, (d.ports||1)-1); saveStore(); render(); }}, '– Port')
      ); 
      return c;
    })()
  );

  var portsContainer = el('div',{class:'ports-rows'});
  var rows = splitPortsIntoRows(d.ports||1, d);
  var nextPortNum = 1;

  // --- Helper for rendering a single port (normal or dual-link sub-port) ---
  function renderPort(d, p, sub){
    var linked = !!linkForPort(d.id, p, sub);
    var peer = linked ? getPeer(d.id,p,sub) : null;
    var peerDev = peer ? deviceById(peer.deviceId) : null;
    var peerPort = peer ? peer.port : null;
    var alias = aliasFor(d.id,p,sub);

    var node = el('div',{class:'port'+(linked?' connected':''), dataset:{deviceId:d.id, port:p, sub:(sub==null?'':sub)}},
      el('div',{class:'tip'}, linked?'⇄':'+'),
      el('div',{class:'num'}, 'Port '+p+(sub!=null ? '/'+(sub+1) : '')),
      el('div',{class:'alias'}, alias||''),
      el('div',{class:'peer'}, peerPort?String(peerPort):'')
    );

    if (linked && peerDev) paintPortBase(node, peerDev.color || '#22354a');
    else paintPortBase(node, '#0f141d');

    node.addEventListener('click', function(ev){
      var isAlt = ev.altKey;
      if (isAlt){
        var curr = aliasFor(d.id,p,sub);
        var next = prompt('Optional port label (blank to clear):', curr||'');
        if(next===null) return;
        var k = keyFor(d.id,p,sub);
        if(!next.trim()) delete state.portAliases[k]; else state.portAliases[k] = next.trim();
        saveStore(); render(); return;
      }
      if (linkForPort(d.id, p, sub)) {
        clearSelectionOutlines();
        var L1 = linkForPort(d.id, p, sub);
        highlightLink(L1.id, { from:{deviceId:d.id, port:p, sub:sub} });
        pendingPort = null; return;
      }
      if (!pendingPort){
        pendingPort = {deviceId:d.id, port:p, sub:sub};
        clearSelectionOutlines(); outlineForSelection(node, true);
        highlightedLinkId = null; applyRowHighlight();
        return;
      }
      if (linkForPort(d.id, p, sub) || linkForPort(pendingPort.deviceId, pendingPort.port, pendingPort.sub)){
        alert('Already linked. Unlink first.');
        pendingPort = null; clearSelectionOutlines(); highlightLink(null); return;
      }
      var devA = deviceById(pendingPort.deviceId);
      var devB = deviceById(d.id);
      var msg = 'Create link:\n' + (devA?devA.name:'A') + ' Port ' + pendingPort.port + (pendingPort.sub!=null?'/'+(pendingPort.sub+1):'')
                + ' ⇄ ' + (devB?devB.name:'B') + ' Port ' + p + (sub!=null?'/'+(sub+1):'');
      if (!confirm(msg)){ pendingPort = null; clearSelectionOutlines(); return; }
      var target = {deviceId:d.id, port:p, sub:sub};
      var ok = connectPorts(pendingPort, target);
      pendingPort = null;
      if (!ok){ alert('Could not connect.'); clearSelectionOutlines(); highlightLink(null); }
    });
    return node;
  }
  // --- end helper ---

  rows.forEach(function(count){
    var row = el('div',{class:'port-row'});
    row.style.gridTemplateColumns = fullWidth ? 'repeat(12, var(--portW12))' : 'repeat(6, var(--portW6))';
    row.style.gap = fullWidth ? 'var(--portGapFull)' : 'var(--portGapHalf)';

    for (var i=0;i<count;i++){
      var p = nextPortNum++;
      if (d.dualLink){
        for (var sub=0; sub<2; sub++){
          row.appendChild(renderPort(d, p, sub));
        }
      } else {
        row.appendChild(renderPort(d, p, null));
      }
    }
    portsContainer.appendChild(row);
  });

  var card = el('div',{class:'device', id:'dev-'+d.id}, head, meta, portsContainer);
  wrap.appendChild(card);
  return wrap;
}



function changePorts(d, newCount){
  newCount = Math.max(1, Math.min(512, Number(newCount)||1));
  if (newCount === d.ports) return;
  if (newCount < d.ports){
    state.links = state.links.filter(function(L){
      var aOk = !(L.a.deviceId===d.id && L.a.port>newCount);
      var bOk = !(L.b.deviceId===d.id && L.b.port>newCount);
      return aOk && bOk;
    });
    Object.keys(state.portAliases).forEach(function(k){
      var parts = k.split(':'); if(parts[0]===d.id && Number(parts[1])>newCount) delete state.portAliases[k];
    });
  }
  d.ports = newCount;
}

/* ========= CONNECTIONS TABLE ========= */
function renderConnections(){
  var tb = $('#connBody'); tb.innerHTML='';
  var q = ($('#searchBox').value||'').toLowerCase().trim();
  var rows = [];
  state.links.forEach(function(L, i){
    var da = deviceById(L.a.deviceId), db = deviceById(L.b.deviceId);
    var aName = (da&&da.name)||'Unknown', bName = (db&&db.name)||'Unknown';
    var aAlias = aliasFor(L.a.deviceId,L.a.port), bAlias = aliasFor(L.b.deviceId,L.b.port);

    // Flexible search terms for ports (4 / port4 / port 4)
    var parts = [
      aName, 'port'+L.a.port, 'port '+L.a.port, String(L.a.port),
      bName, 'port'+L.b.port, 'port '+L.b.port, String(L.b.port),
      aAlias, bAlias
    ];
    var text = parts.join(' ').toLowerCase();
    if(q && text.indexOf(q)===-1) return;

    var aColor = da ? (da.color || '#ccc') : '#ccc';
    var bColor = db ? (db.color || '#ccc') : '#ccc';

    var tr = el('tr',{class:'conn-row', dataset:{linkId:L.id}},
      el('td',{}, String(i+1)),
      el('td',{class:'deviceA'}, el('span',{class:'devName', style:'--devColor:'+aColor, title:aName}, aName)),
      el('td',{}, 'Port '+L.a.port),
      el('td',{}, aAlias),
      el('td',{}, '⇄'),
      el('td',{class:'deviceB'}, el('span',{class:'devName', style:'--devColor:'+bColor, title:bName}, bName)),
      el('td',{}, 'Port '+L.b.port),
      el('td',{}, bAlias),
      el('td',{}, el('button',{class:'btn-danger',onclick:function(){ unlink(L.id); }},'Unlink'))
    );

    // Toggle select/deselect on row click
    tr.addEventListener('click', function(e){
      if(e.target.tagName.toLowerCase()==='button') return;
      if (highlightedLinkId === L.id){ highlightLink(null); clearSelectionOutlines(); return; }
      highlightLink(L.id);
      var portA = document.querySelector('.port[data-device-id="'+L.a.deviceId+'"][data-port="'+L.a.port+'"]');
      var portB = document.querySelector('.port[data-device-id="'+L.b.deviceId+'"][data-port="'+L.b.port+'"]');
      clearSelectionOutlines(); if(portA) outlineForSelection(portA); if(portB) outlineForSelection(portB);
    });

    rows.push(tr);
  });
  if(!rows.length){
    tb.appendChild(el('tr',{}, el('td',{colspan:'9',class:'muted'},'No connections found.')));
  } else rows.forEach(function(r){ tb.appendChild(r); });
  applyRowHighlight();
}
function applyRowHighlight(){
  $all('.conn-row').forEach(function(r){
    if(highlightedLinkId && r.dataset.linkId===highlightedLinkId) r.classList.add('highlight');
    else r.classList.remove('highlight');
  });
}
function highlightLink(linkId, opts){
  highlightedLinkId = linkId; applyRowHighlight(); clearSelectionOutlines();
  if (!linkId) return;
  var L = state.links.find(function(x){ return x.id===linkId; }); if (!L) return;
  var portA = document.querySelector('.port[data-device-id="'+L.a.deviceId+'"][data-port="'+L.a.port+'"]');
  var portB = document.querySelector('.port[data-device-id="'+L.b.deviceId+'"][data-port="'+L.b.port+'"]');
  if (portA) outlineForSelection(portA);
  if (portB) outlineForSelection(portB);
  if (opts && opts.from){
    var originSel = '.port[data-device-id="'+opts.from.deviceId+'"][data-port="'+opts.from.port+'"]';
    var origin = document.querySelector(originSel);
    if (origin){ var border = bestTextColorFor(origin.style.background || '#0f141d'); origin.style.outline = '5px solid ' + border; }
  }
}

/* ========= PRINT ========= */
function openPrintSheet(includeTable){
  var devicesHTML = document.getElementById('devRows').innerHTML;
  var tableEl = document.getElementById('connTable');
  var tableHTML = (includeTable && tableEl) ? tableEl.outerHTML : '';
  var when = new Date().toLocaleString();

  var css = ''
  + ':root{--ink:#111;--line:#ddd;--portGapFull:9px;--portGapHalf:8px;--portW12:60px;--portW6:60px}\n'
  + '*{box-sizing:border-box}\n'
  + 'body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:var(--ink);background:#fff;margin:16px}\n'
  + 'header{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:12px}\n'
  + 'h1{font-size:18px;margin:0}\n'
  + '.meta{font-size:12px;color:#555}\n'
  + '.dev-rows{display:flex;flex-direction:column;gap:12px}\n'
  + '.dev-row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}\n'
  + '.device-wrap{width:100%}\n'
  + '.device-wrap.full{grid-column:1 / -1}\n'
  + '.device{background:#fff;border:1px solid var(--line);border-radius:10px;padding:12px}\n'
  + '.device-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}\n'
  + '.device-title{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px}\n'
  + '.device-actions{display:none!important}\n'
  + '.inline-controls{display:none!important}\n'
  + '.swatch{width:12px;height:12px;border-radius:3px;border:1px solid #0003;display:inline-block}\n'
  + '.ports-rows{display:flex;flex-direction:column;gap:8px;margin-top:10px}\n'
  + '.port-row{display:grid;gap:var(--portGapHalf)}\n'
  + '.port{border:1px solid var(--line);border-radius:8px;padding:6px 6px 4px;text-align:center}\n'
  + '.port .tip{display:none}\n'
  + '.port .num{font-size:12px;font-weight:600}\n'
  + '.port .alias{font-size:11px;margin-top:2px;min-height:1.1em}\n'
  + '.port .peer{font-size:12px;font-weight:700;margin-top:4px;min-height:1.2em}\n'
  + 'table{width:100%;border-collapse:collapse;margin-top:16px}\n'
  + 'th,td{border:1px solid var(--line);padding:6px 8px;font-size:12px;text-align:left}\n'
  + 'th{background:#f6f7f9;font-weight:700}\n'
  + 'th:last-child, td:last-child{display:none}\n'
  + '@media print{.controls{display:none}body{margin:0;padding:16px}}\n';

  var html = ''
  + '<!doctype html><html><head><meta charset="utf-8">'
  + '<title>Port Map – Print</title><style>' + css + '</style></head><body>'
  + '<header><h1>Ethernet Port Map</h1><div class="meta">' + when + '</div></header>'
  + '<section id="printDevices" class="dev-rows">' + devicesHTML + '</section>'
  + (tableHTML ? '<h2 style="font-size:16px;margin:18px 0 8px">Connections</h2>' + tableHTML : '')
  + '<div class="controls" style="margin-top:12px"><button onclick="window.print()">Print</button></div>'
  + '<script>(function(){'
  + 'var host=document.getElementById("printDevices");'
  + 'var Wrows=(host&&host.getBoundingClientRect?host.getBoundingClientRect().width:800)||800;'
  + 'var GAP_COL=12, PAD_BRD=26; var gapFull=9, gapHalf=8;'
  + 'var WfullInner=Wrows - PAD_BRD;'
  + 'var WhalfInner=(Wrows - GAP_COL)/2 - PAD_BRD;'
  + 'var portW12=(WfullInner - (11*gapFull))/12;'
  + 'var portW6 =(WhalfInner - (5*gapHalf))/6;'
  + 'portW12=Math.max(40,portW12); portW6=Math.max(40,portW6);'
  + 'document.documentElement.style.setProperty("--portW12", portW12+"px");'
  + 'document.documentElement.style.setProperty("--portW6",  portW6 +"px");'
  + 'document.documentElement.style.setProperty("--portGapFull", gapFull+"px");'
  + 'document.documentElement.style.setProperty("--portGapHalf", gapHalf+"px");'
  + 'var rows=document.querySelectorAll(".port-row");'
  + 'for(var i=0;i<rows.length;i++){var row=rows[i];var wrap=row.closest(".device-wrap");var isFull=wrap&&wrap.classList.contains("full");'
  + 'row.style.gridTemplateColumns=isFull? "repeat(12, var(--portW12))" : "repeat(6, var(--portW6))";'
  + 'row.style.gap=isFull? "var(--portGapFull)" : "var(--portGapHalf)";}'
  + 'Array.prototype.forEach.call(document.querySelectorAll("[draggable]"),function(el){el.removeAttribute("draggable");});'
  + '})();<\/script>'
  + '</body></html>';

  var win = window.open('', '_blank'); win.document.open(); win.document.write(html); win.document.close();
}

/* ========= ORCHESTRATION ========= */
function render(){ renderDevices(); renderConnections(); }

/* ========= PALETTE ========= */
(function(){
  var palette = [
    '#ff3b30','#ff9500','#ffcc00','#34c759','#30d158','#5ac8fa','#007aff','#5856d6','#af52de',
    '#ff2d55','#ff6b6b','#ffd166','#06d6a0','#4cd964','#64d2ff','#0a84ff','#5e5ce6','#bf5af2',
    '#e74c3c','#e67e22','#f1c40f','#2ecc71','#1abc9c','#3498db','#2d7cf6','#9b59b6','#8e44ad',
    '#d0021b','#f5a623','#f8e71c','#7ed321','#50e3c2','#4a90e2','#007aff','#9013fe','#b8e986'
  ];
  var modal = document.getElementById('paletteModal');
  var grid = document.getElementById('paletteGrid');
  var openBtn = document.getElementById('openPalette');
  var closeBtn = document.getElementById('paletteClose');
  var previewSidebar = document.getElementById('devColorPreview');
  var chosenColor = '#E74C3C';
  function open(){ modal.style.display='flex'; }
  function close(){ modal.style.display='none'; }
  function updatePreview(){ previewSidebar.style.background = chosenColor; }
  grid.innerHTML = '';
  palette.forEach(function(hex){
    var chip = document.createElement('button');
    chip.className = 'chip'; chip.style.background = hex; chip.title = hex;
    chip.addEventListener('click', function(){ chosenColor = hex; updatePreview(); close(); });
    grid.appendChild(chip);
  });
  openBtn.addEventListener('click', open); closeBtn.addEventListener('click', close); updatePreview();
  document.getElementById('addBtn').addEventListener('click', function(){
    var name = document.getElementById('devName').value.trim();
    var ports = Math.max(1, Math.min(512, Number(document.getElementById('devPorts').value)||1));
    if(!name){ alert('Please enter a device name.'); return; }
    state.devices.push({ id:uid(), name:name, ports:ports, color:chosenColor, forceFullRow:false, midWrapMode:'balanced', smallWrap:false });
    document.getElementById('devName').value=''; saveStore(); render();
  });
})();
</script>
<script>
/* ========= LAYOUT MODAL ========= */
function openLayoutModal(deviceId){
  var d = deviceById(deviceId); if (!d) return;
  var backdrop = $('#layoutModal');
  var nameEl = $('#layoutDeviceName');
  var selFull = $('#layoutFullRow');
  var selMid = $('#layoutMidWrap');
  var selSmall = $('#layoutSmallWrap');
  var rowMid = $('#optMidWrap');
  var rowSmall = $('#optSmallWrap');
  var selDual = $('#layoutDualLink');

  nameEl.textContent = d.name + ' ('+d.ports+' ports)';
  selFull.value = d.forceFullRow ? 'full' : 'auto';
  selMid.value = (d.midWrapMode === 'twelve') ? 'twelve' : 'balanced';
  selSmall.value = d.smallWrap ? 'split' : 'single';
  selDual.value = d.dualLink ? 'on' : 'off';

  // Show/hide rows according to port count
  rowMid.style.display   = (d.ports>=13 && d.ports<=24) ? 'flex' : 'none';
  rowSmall.style.display = (d.ports>=7  && d.ports<=12) ? 'flex' : 'none';

  $('#layoutCancel').onclick = function(){ backdrop.style.display='none'; };
  $('#layoutSave').onclick = function(){
    d.forceFullRow = (selFull.value === 'full');
	d.dualLink = (selDual.value === 'on');
    if (d.ports>=13 && d.ports<=24) d.midWrapMode = (selMid.value === 'twelve') ? 'twelve' : 'balanced';
    if (d.ports>=7  && d.ports<=12) d.smallWrap   = (selSmall.value === 'split');
    saveStore(); backdrop.style.display='none'; render();
  };
  backdrop.style.display = 'flex';
}

/* ========= PROFILES UI (combined refresh & select) ========= */
function refreshProfileSelect(){
  var sel = document.getElementById('profileSelect');
  sel.innerHTML = '';
  Object.keys(store.profiles).forEach(function(name){
    var opt = document.createElement('option');
    opt.value = name; opt.textContent = name;
    if (name === store.current) opt.selected = true;
    sel.appendChild(opt);
  });
  sel.value = store.current; // ensure visible selection
}
function switchProfile(name){
  if (!store.profiles[name]) return;
  store.current = name;
  state = store.profiles[name];
  normalizeState(state);
  saveStore();
  refreshProfileSelect(); // rebuild & select
  render();
}
document.getElementById('profileSelect').addEventListener('change', function(){ switchProfile(this.value); });

document.getElementById('newProfileBtn').addEventListener('click', function(){
  var name = prompt('New profile name:', 'Customer '+(Object.keys(store.profiles).length+1));
  if(!name) return; name = name.trim(); if(!name) return;
  if(store.profiles[name]){ alert('A profile with that name already exists.'); return; }
  store.profiles[name] = deepClone(defaultState); saveStore();
  refreshProfileSelect(); switchProfile(name);
});

document.getElementById('renameProfileBtn').addEventListener('click', function(){
  var current = store.current;
  var name = prompt('Rename profile "'+current+'" to:', current);
  if(!name || !name.trim()) return; name = name.trim();
  if(name === current) return;
  if(store.profiles[name]){ alert('A profile with that name already exists.'); return; }
  store.profiles[name] = state; delete store.profiles[current];
  store.current = name; saveStore(); refreshProfileSelect(); render();
});

document.getElementById('duplicateProfileBtn').addEventListener('click', function(){
  var base = store.current;
  var name = prompt('Duplicate profile as:', base+' (copy)');
  if(!name || !name.trim()) return; name = name.trim();
  if(store.profiles[name]){ alert('A profile with that name already exists.'); return; }
  store.profiles[name] = deepClone(state); saveStore();
  refreshProfileSelect(); switchProfile(name);
});

document.getElementById('deleteProfileBtn').addEventListener('click', function(){
  var names = Object.keys(store.profiles);
  if(names.length<=1){ alert('You must keep at least one profile.'); return; }
  var current = store.current;
  if(!confirm('Delete profile "'+current+'"? This cannot be undone.')) return;
  delete store.profiles[current];
  var next = Object.keys(store.profiles)[0];
  store.current = next; state = store.profiles[next];
  saveStore(); refreshProfileSelect(); render();
});

/* Export profile (includes profileName) */
document.getElementById('exportProfileBtn').addEventListener('click', function(){
  var data = { profileName: store.current, devices: state.devices, links: state.links, portAliases: state.portAliases, exportedAt: new Date().toISOString() };
  var blob = new Blob([JSON.stringify(data,null,2)], {type:'application/json'});
  var url = URL.createObjectURL(blob); var a = document.createElement('a');
  a.href = url; a.download = 'ethernet-profile-' + store.current.replace(/[^a-z0-9-_]+/gi,'_') + '.json';
  document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
});

/* Import profile (suggest name, switch active, dropdown syncs) */
document.getElementById('importProfileBtn').addEventListener('click', function(){
  document.getElementById('importProfileFile').click();
});
document.getElementById('importProfileFile').addEventListener('change', function(e){
  var file = e.target.files && e.target.files[0]; if(!file) return;
  var reader = new FileReader();
  reader.onload = function(){
    try{
      var parsed = JSON.parse(reader.result);
      if(!Array.isArray(parsed.devices) || !Array.isArray(parsed.links)) throw new Error('Invalid schema');
      parsed.portAliases = parsed.portAliases || {};

      var devices = parsed.devices.map(function(d){
        return {
          id: d.id || uid(),
          name: String(d.name || 'Unnamed'),
          ports: Math.max(1, Math.min(512, Number(d.ports)||1)),
          color: d.color || '#888',
          forceFullRow: !!d.forceFullRow,
          midWrapMode: (d.midWrapMode === 'twelve' ? 'twelve' : 'balanced'),
          smallWrap: !!d.smallWrap
        };
      });
      var idSet = Object.create(null); devices.forEach(function(d){ idSet[d.id]=true; });

      var links = (parsed.links || []).map(function(L){
        return { id: L.id || uid(), a:{deviceId:String(L.a.deviceId), port:Number(L.a.port)}, b:{deviceId:String(L.b.deviceId), port:Number(L.b.port)} };
      }).filter(function(L){ return idSet[L.a.deviceId] && idSet[L.b.deviceId] && L.a.port>=1 && L.b.port>=1; });

      var suggested = (parsed.profileName || '').toString().trim() || ('Imported ' + new Date().toLocaleString());
      var name = prompt('Name for imported profile:', suggested);
      if(!name || !name.trim()) return; name = name.trim();
      if(store.profiles[name]){ alert('A profile with that name already exists.'); return; }

      store.profiles[name] = { devices: devices, links: links, portAliases: parsed.portAliases };
      saveStore();
      refreshProfileSelect();    // show it in the dropdown
      switchProfile(name);       // switch + re-render + dropdown select
    }catch(err){ alert('Import failed: ' + err.message); }
    finally{ e.target.value=''; }
  };
  reader.readAsText(file);
});

/* ========= BACKUP / RESTORE (all profiles) ========= */
document.getElementById('backupAllBtn').addEventListener('click', function(){
  var blob = new Blob([JSON.stringify(store,null,2)], {type:'application/json'});
  var url = URL.createObjectURL(blob); var a = document.createElement('a');
  a.href = url; a.download = 'ethernet-all-profiles-backup.json';
  document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
});
document.getElementById('restoreAllBtn').addEventListener('click', function(){
  document.getElementById('restoreAllFile').click();
});
document.getElementById('restoreAllFile').addEventListener('change', function(e){
  var file = e.target.files && e.target.files[0]; if(!file) return;
  var reader = new FileReader();
  reader.onload = function(){
    try{
      var parsed = JSON.parse(reader.result);
      if(!parsed || !parsed.current || !parsed.profiles) throw new Error('Invalid backup file (missing profiles).');
      Object.keys(parsed.profiles).forEach(function(name){ normalizeState(parsed.profiles[name]); });
      store = parsed; state = store.profiles[store.current] || deepClone(defaultState);
      saveStore(); refreshProfileSelect(); render();
    }catch(err){ alert('Restore failed: ' + err.message); }
    finally{ e.target.value=''; }
  };
  reader.readAsText(file);
});

/* ========= SEARCH + PRINT ========= */
document.getElementById('searchBox').addEventListener('input', renderConnections);
document.getElementById('printSheet').addEventListener('click', function(){ openPrintSheet(true); });

/* ========= CLEAR ALL (current profile only) ========= */
document.getElementById('clearAll').addEventListener('click', function(){
  if(!confirm('Delete ALL devices and connections in profile "'+store.current+'"?')) return;
  store.profiles[store.current] = deepClone(defaultState);
  state = store.profiles[store.current];
  saveStore(); render();
});

/* ========= GLOBAL BLANK-SPACE CLICK: CLEAR SELECTION ========= */
document.addEventListener('click', function(e){
  if (
    e.target.closest('.port') ||
    e.target.closest('.conn-row') ||
    e.target.closest('.palette-backdrop') ||
    e.target.closest('.modal-backdrop') ||
    e.target.closest('.device-actions') ||
    e.target.closest('button, input, select, label')
  ){ return; }
  pendingPort = null; highlightLink(null); clearSelectionOutlines();
}, true);

/* ========= INIT ========= */
function initProfilesUI(){ refreshProfileSelect(); }
initProfilesUI();
render();
updateUniformPortWidth();
</script>
</body>
</html>
_index.html
wget 'https://lists2.roe3.org/_index.html'
View Content
<!--DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"-->

<HTML>

<HEAD><TITLE>Under construction</TITLE></HEAD>

<BODY BGCOLOR="#FFFFFF"><H1>This web site is under construction</H1></BODY>

</HTML>
adminer-psql.php
wget 'https://lists2.roe3.org/adminer-psql.php'
View Content
admsnippets.zip
wget 'https://lists2.roe3.org/admsnippets.zip'
View Content
check-email.php
wget 'https://lists2.roe3.org/check-email.php'
View Content
<!DOCTYPE html>
<html>
  <head>
    <title>Check Email Validity</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/pure-min.css" integrity="sha384-X38yfunGUhNzHpBaEBsWLO+A0HDYOQi8ufWDkZ0k9e0eXz/tH3II7uKZ9msv++Ls" crossorigin="anonymous">
    <link
      rel="stylesheet"
      href="//fonts.googleapis.com/css?family=Arial,Roboto,San-Serif"
      type="text/css"
    />
<style>
body {
  margin-top: 2em;
  margin-left: 2em;
  }
fieldset {
  padding: 3em;
  width: fit-content;
  }
legend {
  font-size: 16pt;
  }
</style>
  </head>
  <body>
<fieldset>
<legend><b>Check Email Address Validity</b></legend>
<form action="" method="POST">
<label>Email Address:</label>
<input type="text" name="email" length="70">
<input type="submit">
</form>
<?php
//$email = "mdrone@roe3.org";
$email = $_POST["email"];
echo "<h2>Method 1: Using filter_var()</h2>";

if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
echo "✅ Valid email: $email";
} else {
echo "❌ Invalid email: $email";
}
echo "<h2>Method 2: Using Regular Expressions</h2>";

if (preg_match("/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/", $email)) {
echo "✅ Valid email: $email";
} else {
echo "❌ Invalid email: $email";
}

echo "<h2>Method 3: Checking MX Records</h2>";
list($user, $domain) = explode('@', $email);

if (checkdnsrr($domain, "MX")) {
echo "✅ Domain accepts emails: $domain";
} else {
echo "❌ Domain does not accept emails: $domain";
}
?>
</fieldset>
</body>
</html>
feedsmith.html
wget 'https://lists2.roe3.org/feedsmith.html'
View Content
<html>


<script type="module">
  import { generateRssFeed } from 'https://cdn.jsdelivr.net/npm/feedsmith@latest/dist/index.js'

const rss = generateRssFeed({
  title: 'My Feed',
  link: 'https://lists2.roe3.org/mdrone/',
  description: 'A test',
  items: [{
    title: 'My Feed',
    link: 'https://lists2.roe3.org/mdrone/anniversary/',
    description: 'Swornes Anniversary',
    pubDate: new Date()
  }]
})

console.log(rss) // Complete RSS XML

</script>

</html>
mailarchiva.service.txt
wget 'https://lists2.roe3.org/mailarchiva.service.txt'
View Content
[Unit]
Description=MailArchiva
After=network.target
RequiresMountsFor=/var/opt/mailarchiva /opt/mailarchiva /var/log/mailarchiva

[Service]
Environment="JAVA_OPTS=-Djava.awt.headless=true"

# Lifecycle
Type=forking
WorkingDirectory=/opt/mailarchiva/server/
PIDFile=/opt/mailarchiva/mailarchiva.pid
ExecStart=/bin/sh /opt/mailarchiva/server/startserver
ExecStop=/bin/sh /opt/mailarchiva/server/stopserver
SuccessExitStatus=143
TimeoutStopSec=10m
Environment=RACK_ENV=production
OOMScoreAdjust=-800

# Logging
SyslogIdentifier=mailarchiva

# Security
User=root
ReadWritePaths=/var/opt/mailarchiva/
ReadWritePaths=/opt/mailarchiva/
ReadWritePaths=/var/log/mailarchiva/

# Auto restart
Restart=on-failure
RestartSec=3

[Install]
WantedBy=multi-user.target

phpinfo.php
wget 'https://lists2.roe3.org/phpinfo.php'
View Content
<?php
echo phpversion();
phpinfo();
?>

swapshop.zip
wget 'https://lists2.roe3.org/swapshop.zip'
View Content
tip-calculator.html
wget 'https://lists2.roe3.org/tip-calculator.html'
View Content
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="theme-color" content="#2f855a">
  <title>Tip Calculator &amp; Bill Splitter</title>
  <meta name="description" content="Free tip calculator and bill splitter. Instantly calculate how much to tip and split the bill among friends. Supports custom tip %, pre-tax tipping, and rounding options. No signup needed.">
  <!-- Structured Data -->
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@graph": [
      {
        "@type": "SoftwareApplication",
        "name": "Tip Calculator & Bill Splitter",
        "url": "https://lists2.roe3.org/tip-calculator.html",
        "description": "Free tip calculator and bill splitter. Instantly calculate how much to tip and split the bill among friends. Supports custom tip %, pre-tax tipping, and rounding options.",
        "applicationCategory": "UtilitiesApplication",
        "operatingSystem": "Web",
        "offers": {
          "@type": "Offer",
          "price": "0",
          "priceCurrency": "USD"
        }
      },
      {
        "@type": "FAQPage",
        "mainEntity": [
          {
            "@type": "Question",
            "name": "What is a good tip percentage at a restaurant?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "The standard restaurant tip in the US is 18–22% of the pre-tax bill. 20% has become the new baseline for good service at sit-down restaurants. 15% is acceptable for average service; 25% or more for exceptional service."
            }
          },
          {
            "@type": "Question",
            "name": "Do you tip on the tax or before tax?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Traditionally, tips are calculated on the pre-tax amount. However, most people tip on the total bill (including tax) because it's easier and the difference is small."
            }
          },
          {
            "@type": "Question",
            "name": "Should you tip on takeout or delivery orders?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "For food delivery, tip 15–20% of the order total (or a minimum of $3–5). For takeout (you pick up), tipping is optional — 10% or a dollar or two is appreciated but not expected."
            }
          },
          {
            "@type": "Question",
            "name": "Is 15% still an acceptable tip?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "In the current US tipping culture, 15% is considered a minimum acceptable tip for satisfactory service. Most service workers rely heavily on tips. 18–20% is now the baseline expectation for good service."
            }
          },
          {
            "@type": "Question",
            "name": "How do you split a bill when people ordered different amounts?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "The fairest approach is to split based on what each person ordered — add each person's items, apply their share of the tip, and each pays their own total. For simplicity, many groups split the total equally."
            }
          },
          {
            "@type": "Question",
            "name": "What is tip pooling and how is it calculated?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Tip pooling combines all tips collected during a shift and redistributes them among a group of workers — servers, bartenders, bussers, and kitchen staff. To calculate: add all tips received, assign each role a percentage share, and multiply the total pool by each role's percentage. To use this calculator for tip pooling, enter the total tips collected as the bill amount, set tip percentage to 100%, and divide by the number of staff sharing the pool."
            }
          },
          {
            "@type": "Question",
            "name": "How much should you tip in California?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "California follows standard US tipping norms: 18–22% at restaurants, 15–20% for food delivery, and 10–15% for other services. California law requires employers to distribute tips solely to workers and prohibits management from taking a share of the tip pool."
            }
          },
          {
            "@type": "Question",
            "name": "How much should you tip a hairdresser or barber?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "The standard tip for a hairdresser or barber is 15–20% of the total service cost. For a cut that costs $40, tip $6–$8. If you received exceptional service — a complex style, extra time, or a last-minute appointment — 20–25% is appropriate. Some people tip their regular stylist more generously to maintain the relationship."
            }
          },
          {
            "@type": "Question",
            "name": "Should you tip at a buffet?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "At a buffet, tipping $1–$2 per person is customary since you serve yourself but staff still clear plates, refill drinks, and keep the area clean. At higher-end buffets where staff are more attentive, 10% of the total bill is a common guideline. You are not obligated to tip the same 18–20% you would at a full-service restaurant."
            }
          },
          {
            "@type": "Question",
            "name": "How much should you tip hotel housekeeping?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "The American Hotel &amp; Lodging Association recommends tipping hotel housekeeping $1–$5 per night. For longer stays or more demanding service (extra towels, deep cleaning), $5 per night is a fair amount. Leave the tip daily — housekeeping staff may rotate, so a single end-of-stay tip may not reach the person who cleaned your room each day."
            }
          },
          {
            "@type": "Question",
            "name": "How much should you tip in New York?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "New York has some of the highest tipping expectations in the US. At restaurants, 20–25% is standard — many New Yorkers double the tax (NYC tax is 8.875%, doubled is about 18%) as a quick baseline, though 20% is now more typical. For taxis and rideshares, 15–20% is expected. Bartenders receive $1–$2 per drink or 20% of the tab. Food delivery typically warrants 15–20%. New York's high cost of living means many service workers rely heavily on tips."
            }
          },
          {
            "@type": "Question",
            "name": "How do you calculate a tip for a large group?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "For a large group, enter the total bill, choose a tip percentage (18–20% is typical), and set the party size — the calculator splits it automatically. Note that many restaurants add an automatic gratuity of 18–20% for parties of 6 or more. Always check the menu or ask your server before adding a second tip on top of an automatic gratuity."
            }
          },
          {
            "@type": "Question",
            "name": "How much should you tip for rideshare (Uber, Lyft)?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "A standard rideshare tip is 15–20% of the fare, or $2–$5 for short rides. Tip more for help with luggage, long waits, or rides in bad weather. Both Uber and Lyft allow tipping up to 30 days after the trip. Rideshare tips go 100% to the driver — the platform takes no cut."
            }
          }
        ]
      }
    ]
  }
  </script>

  <style>
    :root {
      --bg: #f5f7fa;
      --surface: #ffffff;
      --surface2: #f0f4f8;
      --border: #dde3ec;
      --text: #1a202c;
      --text2: #4a5568;
      --text3: #718096;
      --accent: #2f855a;
      --accent-hover: #276749;
      --accent-light: #c6f6d5;
      --accent-text: #22543d;
      --preset-bg: #edf2f7;
      --preset-hover: #e2e8f0;
      --preset-active-bg: #2f855a;
      --preset-active-text: #ffffff;
      --result-bg: #f0fff4;
      --result-border: #9ae6b4;
      --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.06);
      --radius: 12px;
      --radius-sm: 8px;
    }
    [data-theme="dark"] {
      --bg: #0f1117;
      --surface: #1a1d27;
      --surface2: #252836;
      --border: #2d3147;
      --text: #f0f4f8;
      --text2: #a8b5c8;
      --text3: #6b7a94;
      --accent: #48bb78;
      --accent-hover: #68d391;
      --accent-light: #1a3a2a;
      --accent-text: #9ae6b4;
      --preset-bg: #252836;
      --preset-hover: #2d3147;
      --preset-active-bg: #48bb78;
      --preset-active-text: #0f1117;
      --result-bg: #1a2e22;
      --result-border: #2f6646;
      --shadow: 0 1px 3px rgba(0,0,0,0.3), 0 4px 16px rgba(0,0,0,0.2);
    }

    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
      background: var(--bg);
      color: var(--text);
      min-height: 100vh;
      transition: background 0.2s, color 0.2s;
    }

    /* ── Header ── */
    header {
      background: var(--surface);
      border-bottom: 1px solid var(--border);
      padding: 14px 20px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      position: sticky;
      top: 0;
      z-index: 10;
    }
    .logo a {
      text-decoration: none;
      font-weight: 700;
      font-size: 1.1rem;
      color: var(--accent);
      letter-spacing: -0.3px;
    }
    .logo span { color: var(--text3); font-weight: 400; }
    .dark-toggle {
      background: var(--surface2);
      border: 1px solid var(--border);
      border-radius: 20px;
      padding: 6px 12px;
      cursor: pointer;
      font-size: 0.85rem;
      color: var(--text2);
      display: flex;
      align-items: center;
      gap: 6px;
      transition: background 0.15s;
    }
    .dark-toggle:hover { background: var(--preset-hover); }
    .header-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
    .btn-share {
      background: var(--accent);
      border: none;
      border-radius: 20px;
      padding: 6px 14px;
      cursor: pointer;
      font-size: 0.85rem;
      font-weight: 600;
      color: white;
      display: flex;
      align-items: center;
      gap: 5px;
      transition: background 0.15s;
    }
    .btn-share:hover { background: var(--accent-hover); }
    @media (max-width: 480px) { .btn-share span.share-label { display: none; } }

    /* ── Top AdSense ── */
    .ad-top {
      max-width: 760px;
      margin: 16px auto 0;
      padding: 0 16px;
      text-align: center;
    }

    /* ── Main ── */
    main {
      max-width: 760px;
      margin: 0 auto;
      padding: 24px 16px 48px;
    }

    h1 {
      font-size: clamp(1.5rem, 4vw, 2rem);
      font-weight: 800;
      color: var(--text);
      margin-bottom: 6px;
      letter-spacing: -0.5px;
    }
    .subtitle {
      color: var(--text3);
      font-size: 0.95rem;
      margin-bottom: 24px;
    }

    /* ── Card ── */
    .card {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: var(--radius);
      padding: 24px;
      box-shadow: var(--shadow);
      margin-bottom: 20px;
    }
    .card-title {
      font-size: 0.8rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.8px;
      color: var(--text3);
      margin-bottom: 14px;
    }

    /* ── Form elements ── */
    .field { margin-bottom: 18px; }
    .field:last-child { margin-bottom: 0; }
    label {
      display: block;
      font-size: 0.9rem;
      font-weight: 600;
      color: var(--text2);
      margin-bottom: 7px;
    }
    .input-wrap {
      position: relative;
      display: flex;
      align-items: center;
    }
    .input-prefix {
      position: absolute;
      left: 12px;
      color: var(--text3);
      font-size: 1rem;
      font-weight: 600;
      pointer-events: none;
    }
    .input-suffix {
      position: absolute;
      right: 12px;
      color: var(--text3);
      font-size: 1rem;
      pointer-events: none;
    }
    input[type="number"] {
      width: 100%;
      padding: 12px 14px;
      border: 1.5px solid var(--border);
      border-radius: var(--radius-sm);
      background: var(--surface);
      color: var(--text);
      font-size: 1.05rem;
      transition: border-color 0.15s, box-shadow 0.15s;
      -moz-appearance: textfield;
      appearance: textfield;
    }
    input[type="number"]::-webkit-outer-spin-button,
    input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; }
    input[type="number"]:focus {
      outline: none;
      border-color: var(--accent);
      box-shadow: 0 0 0 3px rgba(72,187,120,0.15);
    }
    .has-prefix input[type="number"] { padding-left: 30px; }
    .has-suffix input[type="number"] { padding-right: 36px; }

    /* ── Tip presets ── */
    .presets {
      display: flex;
      gap: 8px;
      flex-wrap: wrap;
      margin-bottom: 10px;
    }
    .preset-btn {
      flex: 1;
      min-width: 56px;
      padding: 10px 6px;
      border: 1.5px solid var(--border);
      border-radius: var(--radius-sm);
      background: var(--preset-bg);
      color: var(--text2);
      font-size: 0.95rem;
      font-weight: 600;
      cursor: pointer;
      text-align: center;
      transition: background 0.12s, color 0.12s, border-color 0.12s;
    }
    .preset-btn:hover { background: var(--preset-hover); }
    .preset-btn.active {
      background: var(--preset-active-bg);
      color: var(--preset-active-text);
      border-color: var(--preset-active-bg);
    }
    .custom-tip-row {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-top: 10px;
    }
    .custom-tip-label {
      font-size: 0.85rem;
      color: var(--text3);
      white-space: nowrap;
    }

    /* ── Toggle row ── */
    .toggle-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 10px 0;
      border-top: 1px solid var(--border);
    }
    .toggle-label {
      font-size: 0.9rem;
      color: var(--text2);
    }
    .toggle-label small {
      display: block;
      font-size: 0.78rem;
      color: var(--text3);
      font-weight: 400;
    }
    .switch {
      position: relative;
      display: inline-block;
      width: 42px;
      height: 24px;
      flex-shrink: 0;
    }
    .switch input { opacity: 0; width: 0; height: 0; }
    .slider {
      position: absolute;
      inset: 0;
      background: var(--border);
      border-radius: 24px;
      cursor: pointer;
      transition: background 0.2s;
    }
    .slider:before {
      content: '';
      position: absolute;
      width: 18px;
      height: 18px;
      left: 3px;
      top: 3px;
      background: white;
      border-radius: 50%;
      transition: transform 0.2s;
      box-shadow: 0 1px 3px rgba(0,0,0,0.2);
    }
    input:checked + .slider { background: var(--accent); }
    input:checked + .slider:before { transform: translateX(18px); }

    /* ── Rounding selector ── */
    .rounding-options {
      display: flex;
      gap: 8px;
      flex-wrap: wrap;
    }
    .round-btn {
      padding: 8px 12px;
      border: 1.5px solid var(--border);
      border-radius: var(--radius-sm);
      background: var(--preset-bg);
      color: var(--text2);
      font-size: 0.82rem;
      font-weight: 600;
      cursor: pointer;
      transition: background 0.12s, color 0.12s, border-color 0.12s;
    }
    .round-btn:hover { background: var(--preset-hover); }
    .round-btn.active {
      background: var(--preset-active-bg);
      color: var(--preset-active-text);
      border-color: var(--preset-active-bg);
    }

    /* ── Results ── */
    .results-card {
      background: var(--result-bg);
      border: 1.5px solid var(--result-border);
      border-radius: var(--radius);
      padding: 24px;
      box-shadow: var(--shadow);
      margin-bottom: 20px;
    }
    .results-grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 16px;
    }
    .result-item {
      text-align: center;
      padding: 16px 12px;
      background: var(--surface);
      border: 1px solid var(--result-border);
      border-radius: var(--radius-sm);
    }
    .result-item.primary {
      grid-column: span 2;
      background: var(--accent-light);
      border-color: var(--accent);
    }
    .result-label {
      font-size: 0.78rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.6px;
      color: var(--text3);
      margin-bottom: 6px;
    }
    .result-value {
      font-size: 1.8rem;
      font-weight: 800;
      color: var(--accent);
      letter-spacing: -0.5px;
      line-height: 1;
    }
    .result-item.primary .result-value {
      font-size: 2.4rem;
      color: var(--accent);
    }
    .result-sub {
      font-size: 0.78rem;
      color: var(--text3);
      margin-top: 4px;
    }

    /* ── Split detail ── */
    .split-detail {
      margin-top: 14px;
      padding-top: 14px;
      border-top: 1px solid var(--result-border);
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 10px;
    }
    .split-item {
      text-align: center;
      padding: 12px 8px;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: var(--radius-sm);
    }
    .split-label {
      font-size: 0.74rem;
      font-weight: 600;
      color: var(--text3);
      text-transform: uppercase;
      letter-spacing: 0.5px;
      margin-bottom: 4px;
    }
    .split-value {
      font-size: 1.4rem;
      font-weight: 700;
      color: var(--text);
    }

    /* ── Reset ── */
    .btn-reset {
      width: 100%;
      padding: 13px;
      background: var(--surface2);
      border: 1.5px solid var(--border);
      border-radius: var(--radius-sm);
      color: var(--text2);
      font-size: 0.95rem;
      font-weight: 600;
      cursor: pointer;
      transition: background 0.15s;
      margin-top: 4px;
    }
    .btn-reset:hover { background: var(--preset-hover); }

    /* ── Info section ── */
    .info-section {
      margin-top: 32px;
    }
    .info-section h2 {
      font-size: 1.15rem;
      font-weight: 700;
      margin-bottom: 10px;
      color: var(--text);
    }
    .info-section p {
      font-size: 0.9rem;
      color: var(--text2);
      line-height: 1.65;
      margin-bottom: 12px;
    }
    .tipping-guide {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
      gap: 10px;
      margin: 14px 0;
    }
    .tipping-item {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: var(--radius-sm);
      padding: 12px;
    }
    .tipping-service {
      font-size: 0.82rem;
      font-weight: 700;
      color: var(--text2);
      margin-bottom: 3px;
    }
    .tipping-range {
      font-size: 1rem;
      font-weight: 800;
      color: var(--accent);
    }

    /* ── Footer ── */
    footer {
      text-align: center;
      padding: 24px 16px;
      border-top: 1px solid var(--border);
      color: var(--text3);
      font-size: 0.82rem;
    }
    footer a { color: var(--accent); text-decoration: none; }
    footer a:hover { text-decoration: underline; }

    /* Example buttons */
    .example-btns { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
    .example-label { font-size: 0.8rem; color: var(--text3); white-space: nowrap; }
    .btn-example { background: transparent; border: 1px solid var(--border); border-radius: 20px; padding: 3px 11px; font-size: 0.78rem; color: var(--text2); cursor: pointer; transition: border-color 0.15s, color 0.15s; }
    .btn-example:hover { border-color: var(--accent); color: var(--accent); }

    /* ── Responsive ── */
    @media (max-width: 520px) {
      .card { padding: 18px 16px; }
      .results-card { padding: 18px 16px; }
      .result-item.primary .result-value { font-size: 2rem; }
      .results-grid { gap: 10px; }
      .preset-btn { min-width: 46px; font-size: 0.88rem; }
    }
  </style>
</head>
<body>

  <header>
    <div class="logo">
      <a href="https://lists2.roe3.org/tip-calculator.html">⚡ Tip Calculator</span></a>
    </div>
    <div class="header-actions">
      <button class="btn-share" id="shareBtn" onclick="shareThis()" aria-label="Share this tool">&#8593; <span class="share-label">Share</span></button>
    <button class="dark-toggle" id="darkToggle" aria-label="Toggle dark mode">
      <span id="darkIcon">🌙</span> <span id="darkLabel">Dark</span>
    </button>
    </div>
  </header>

<main>
    <h1>Tip Calculator &amp; Bill Splitter</h1>
    <p class="subtitle">Instant results — no button to press. Enter your bill, pick a tip, split among friends.</p>

    <!-- Input Card -->
    <div class="card">
      <div class="card-title">Bill Details</div>

      <div class="example-btns">
        <span class="example-label">Try an example:</span>
        <button class="btn-example" onclick="loadTipExample('lunch')">Lunch $25</button>
        <button class="btn-example" onclick="loadTipExample('dinner')">Dinner $80</button>
        <button class="btn-example" onclick="loadTipExample('group')">Group $200 ÷ 4</button>
      </div>

      <div class="field">
        <label for="billAmount">Bill Amount</label>
        <div class="input-wrap has-prefix">
          <span class="input-prefix">$</span>
          <input type="number" id="billAmount" placeholder="0.00" min="0" step="0.01" inputmode="decimal" autofocus>
        </div>
      </div>

      <div class="field">
        <label>Tip Percentage</label>
        <div class="presets">
          <button class="preset-btn" data-tip="10">10%</button>
          <button class="preset-btn" data-tip="15">15%</button>
          <button class="preset-btn active" data-tip="18">18%</button>
          <button class="preset-btn" data-tip="20">20%</button>
          <button class="preset-btn" data-tip="22">22%</button>
          <button class="preset-btn" data-tip="25">25%</button>
        </div>
        <div class="custom-tip-row">
          <span class="custom-tip-label">Custom:</span>
          <div class="input-wrap has-suffix" style="flex:1">
            <input type="number" id="customTip" placeholder="e.g. 17" min="0" max="100" step="1" inputmode="decimal">
            <span class="input-suffix">%</span>
          </div>
        </div>
      </div>

      <div class="field">
        <label for="numPeople">Split Among</label>
        <div class="input-wrap has-suffix">
          <input type="number" id="numPeople" value="1" min="1" max="100" step="1" inputmode="numeric">
          <span class="input-suffix">people</span>
        </div>
      </div>
    </div>

    <!-- Options Card -->
    <div class="card">
      <div class="card-title">Options</div>

      <div class="toggle-row">
        <div class="toggle-label">
          Tip on pre-tax amount
          <small>Enter tax % to calculate before tipping</small>
        </div>
        <label class="switch">
          <input type="checkbox" id="preTaxToggle">
          <span class="slider"></span>
        </label>
      </div>

      <div class="field" id="taxRateField" style="display:none; margin-top:14px;">
        <label for="taxRate">Tax Rate</label>
        <div class="input-wrap has-suffix">
          <input type="number" id="taxRate" placeholder="e.g. 8.875" min="0" max="50" step="0.1" inputmode="decimal">
          <span class="input-suffix">%</span>
        </div>
      </div>

      <div class="toggle-row" style="margin-top:14px;">
        <div class="toggle-label">
          Rounding
          <small>Round per-person amounts</small>
        </div>
        <div class="rounding-options">
          <button class="round-btn active" data-round="none">Exact</button>
          <button class="round-btn" data-round="nearest">Nearest $1</button>
          <button class="round-btn" data-round="up">Round Up</button>
        </div>
      </div>
    </div>

    <!-- Results -->
    <div class="results-card" id="resultsCard">
      <div class="card-title">Results</div>
      <div class="results-grid">
        <div class="result-item">
          <div class="result-label">Tip Amount</div>
          <div class="result-value" id="resTipAmount">$0.00</div>
          <div class="result-sub" id="resTipPct">18%</div>
        </div>
        <div class="result-item">
          <div class="result-label">Tip Per Person</div>
          <div class="result-value" id="resTipPerPerson">$0.00</div>
          <div class="result-sub" id="resPeopleCount">1 person</div>
        </div>
        <div class="result-item primary">
          <div class="result-label">Total Bill (with tip)</div>
          <div class="result-value" id="resTotalBill">$0.00</div>
          <div class="result-sub" id="resTotalSub"></div>
        </div>
      </div>

      <div class="split-detail" id="splitDetail" style="display:none;">
        <div class="split-item">
          <div class="split-label">Each Person Pays</div>
          <div class="split-value" id="resPerPerson">$0.00</div>
        </div>
        <div class="split-item">
          <div class="split-label">Each Person Tips</div>
          <div class="split-value" id="resPerPersonTip">$0.00</div>
        </div>
      </div>
    </div>

    <button class="btn-reset" id="resetBtn">↺ Reset Calculator</button>

    <!-- Bottom AdSense -->
    <!-- ADSENSE -->

    <!-- Tipping Guide -->
    <div class="info-section">
      <h2>Standard Tipping Guide (US)</h2>
      <div class="tipping-guide">
        <div class="tipping-item">
          <div class="tipping-service">Restaurant (sit-down)</div>
          <div class="tipping-range">18–22%</div>
        </div>
        <div class="tipping-item">
          <div class="tipping-service">Buffet</div>
          <div class="tipping-range">10%</div>
        </div>
        <div class="tipping-item">
          <div class="tipping-service">Bar / Drinks</div>
          <div class="tipping-range">15–20%</div>
        </div>
        <div class="tipping-item">
          <div class="tipping-service">Food Delivery</div>
          <div class="tipping-range">15–20%</div>
        </div>
        <div class="tipping-item">
          <div class="tipping-service">Pizza Delivery</div>
          <div class="tipping-range">$3–5 min</div>
        </div>
        <div class="tipping-item">
          <div class="tipping-service">Rideshare / Taxi</div>
          <div class="tipping-range">15–20%</div>
        </div>
        <div class="tipping-item">
          <div class="tipping-service">Hair / Salon</div>
          <div class="tipping-range">15–20%</div>
        </div>
        <div class="tipping-item">
          <div class="tipping-service">Hotel Housekeeping</div>
          <div class="tipping-range">$2–5/night</div>
        </div>
        <div class="tipping-item">
          <div class="tipping-service">Spa / Massage</div>
          <div class="tipping-range">18–20%</div>
        </div>
        <div class="tipping-item">
          <div class="tipping-service">Valet Parking</div>
          <div class="tipping-range">$2–5</div>
        </div>
      </div>

      <h2>How to Calculate a Tip</h2>
      <p>Multiply the bill by the tip percentage (e.g. $50 × 0.18 = $9 tip). Add the tip to the bill for the total ($50 + $9 = $59). To split, divide the total by the number of people ($59 ÷ 3 ≈ $19.67 each).</p>
      <p>For <strong>pre-tax tipping</strong>, first subtract the tax from the bill before applying the tip percentage. This results in a slightly lower tip. Enter your local tax rate above to enable this mode.</p>
      <p>Our calculator handles all of this instantly — no math required.</p>

      <h2>Frequently Asked Questions</h2>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">What is a good tip percentage at a restaurant?</h3>
      <p>The standard restaurant tip in the US is <strong>18–22%</strong> of the pre-tax bill. 20% has become the new baseline for good service at sit-down restaurants. 15% is considered acceptable for average service; 25% or more for exceptional service. At a buffet, 10% is typical since table service is minimal.</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">Do you tip on the tax or before tax?</h3>
      <p>Traditionally, tips are calculated on the <strong>pre-tax amount</strong>. However, most people simply tip on the total bill (including tax) because it's easier and the difference is small. On a $50 bill with 8% tax ($4), tipping 20% pre-tax gives $10 vs. tipping on the total gives $10.80 — a $0.80 difference. Use the pre-tax toggle above if you prefer the traditional method.</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">Should you tip on takeout or delivery orders?</h3>
      <p>For <strong>food delivery</strong>, tip 15–20% of the order total (or a minimum of $3–5). Delivery drivers use their own vehicles and time to bring food to you. For takeout (you pick up), tipping is optional — 10% or a dollar or two is appreciated but not expected. On third-party apps like DoorDash or Uber Eats, tip at least 15% to ensure drivers accept your order promptly.</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">Is 15% still an acceptable tip?</h3>
      <p>In the current US tipping culture, 15% is considered a <strong>minimum acceptable tip</strong> for satisfactory service, not the standard. Most service workers rely heavily on tips as part of their income. If you received good service, 18–20% is now the baseline expectation. 15% is appropriate if service was below average — leaving nothing should be reserved for truly unacceptable service.</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">How do you split a bill when people ordered different amounts?</h3>
      <p>The fairest approach is to <strong>split based on what each person ordered</strong>. Add each person's items, apply their share of the tip (tip percentage on their subtotal), and each pays their own total. For simplicity, many groups split the total equally — use the number of people field above to divide evenly. For uneven splits, calculate each person's subtotal separately and use this tool for each amount.</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">What is tip pooling and how is it calculated?</h3>
      <p><strong>Tip pooling</strong> is when all tips collected during a shift are combined and redistributed among a group of workers — typically servers, bartenders, bussers, and kitchen staff. To calculate a tip pool: (1) add all tips received across the shift; (2) assign each role a percentage share (e.g. servers 60%, bartenders 20%, bussers 20%); (3) multiply the total pool by each role's percentage. To use this tool for tip pooling, enter the total bill as the combined tip amount collected, set tip percentage to 100%, and set the number of people to the number of staff sharing the pool.</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">How much should you tip in California?</h3>
      <p>California follows standard US tipping norms: <strong>18–22% at restaurants</strong>, 15–20% for food delivery, and 10–15% for other services. California is a high minimum-wage state — the minimum wage for restaurant workers is $20/hour as of 2024 — but tipping remains customary and expected. California law requires employers to distribute tips solely to workers and prohibits management from taking a share of the tip pool.</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">How much should you tip a hairdresser or barber?</h3>
      <p>The standard tip for a hairdresser or barber is <strong>15–20% of the total service cost</strong>. For a cut that costs $40, tip $6–$8. If you received exceptional service — a complex style, extra time, or a last-minute appointment — 20–25% is appropriate. Some people tip their regular stylist more generously to maintain the relationship. Use the calculator above: enter the service cost as the bill amount and set your chosen tip percentage.</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">Should you tip at a buffet?</h3>
      <p>At a buffet, tipping <strong>$1–$2 per person</strong> is customary since you serve yourself but staff still clear plates, refill drinks, and keep the area clean. At higher-end buffets where staff are more attentive, 10% of the total bill is a reasonable guideline. You are not obligated to tip the same 18–20% you would at a full-service restaurant.</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">How much should you tip hotel housekeeping?</h3>
      <p>The American Hotel &amp; Lodging Association recommends tipping hotel housekeeping <strong>$1–$5 per night</strong>. For longer stays or more demanding service, $5 per night is a fair amount. Leave the tip daily — housekeeping staff may rotate, so a single end-of-stay tip may not reach the person who cleaned your room each day. Leave cash with a note marked "housekeeping."</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">How much should you tip in New York?</h3>
      <p>New York has some of the highest tipping expectations in the US. At restaurants, <strong>20–25% is standard</strong> — many New Yorkers double the tax (8.875% NYC tax × 2 ≈ 18%) as a quick baseline, though 20% is now more typical. For taxis and rideshares, 15–20% is expected. Bartenders receive $1–$2 per drink or 20% of the tab. Food delivery typically warrants 15–20%, especially in dense neighbourhoods where riders navigate stairs and foot traffic. New York's high cost of living means many service workers rely heavily on tips as part of their income.</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">How do you calculate a tip for a large group?</h3>
      <p>For a large group, use the number-of-people field in this calculator to split the tip automatically. Enter the total bill, choose a tip percentage (18–20% is typical for groups), and set the party size — the calculator shows each person's equal share. If people want to split unevenly based on what they ordered, calculate the tip on the full bill first, then divide manually. Note that many restaurants add an <strong>automatic gratuity of 18–20%</strong> for parties of 6 or more — always check the menu or ask your server before adding a second tip.</p>

      <h3 style="font-size:0.95rem;font-weight:700;margin:16px 0 6px;">How much should you tip for rideshare (Uber, Lyft)?</h3>
      <p>Tipping for rideshare is optional in the app but increasingly expected. A standard tip is <strong>15–20% of the fare</strong>, or $2–$5 for short rides. Tip more for particularly helpful drivers, help with luggage, long waits, or rides in bad weather. Both Uber and Lyft allow tipping up to 30 days after the trip if you forget in the moment. Unlike restaurant tips, rideshare tips go 100% to the driver — the platform takes no cut.</p>

</div>
</main>

  <footer>
    <div>Free tool, no account needed</div>
</footer>

  <script>
    // ── State ──
    let tipPct = 18;
    let rounding = 'none';

    // ── Dark mode ──
    const darkToggle = document.getElementById('darkToggle');
    const darkIcon   = document.getElementById('darkIcon');
    const darkLabel  = document.getElementById('darkLabel');
    const saved = localStorage.getItem('snappy-theme');
    if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.setAttribute('data-theme', 'dark');
      darkIcon.textContent = '☀️';
      darkLabel.textContent = 'Light';
    }
    darkToggle.addEventListener('click', () => {
      const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
      if (isDark) {
        document.documentElement.removeAttribute('data-theme');
        darkIcon.textContent = '🌙';
        darkLabel.textContent = 'Dark';
        localStorage.setItem('snappy-theme', 'light');
      } else {
        document.documentElement.setAttribute('data-theme', 'dark');
        darkIcon.textContent = '☀️';
        darkLabel.textContent = 'Light';
        localStorage.setItem('snappy-theme', 'dark');
      }
    });

    // ── Preset buttons ──
    document.querySelectorAll('.preset-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        tipPct = parseFloat(btn.dataset.tip);
        document.getElementById('customTip').value = '';
        calculate();
      });
    });

    // ── Custom tip ──
    document.getElementById('customTip').addEventListener('input', function() {
      const val = parseFloat(this.value);
      document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
      if (!isNaN(val) && val >= 0) {
        tipPct = val;
      }
      calculate();
    });

    // ── Round buttons ──
    document.querySelectorAll('.round-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('.round-btn').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        rounding = btn.dataset.round;
        calculate();
      });
    });

    // ── Pre-tax toggle ──
    document.getElementById('preTaxToggle').addEventListener('change', function() {
      document.getElementById('taxRateField').style.display = this.checked ? 'block' : 'none';
      calculate();
    });

    // ── Inputs ──
    ['billAmount', 'numPeople', 'taxRate'].forEach(id => {
      const el = document.getElementById(id);
      if (el) el.addEventListener('input', calculate);
    });

    // ── Reset ──
    document.getElementById('resetBtn').addEventListener('click', () => {
      document.getElementById('billAmount').value = '';
      document.getElementById('numPeople').value = '1';
      document.getElementById('customTip').value = '';
      document.getElementById('taxRate').value = '';
      document.getElementById('preTaxToggle').checked = false;
      document.getElementById('taxRateField').style.display = 'none';
      tipPct = 18;
      rounding = 'none';
      document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
      document.querySelector('[data-tip="18"]').classList.add('active');
      document.querySelectorAll('.round-btn').forEach(b => b.classList.remove('active'));
      document.querySelector('[data-round="none"]').classList.add('active');
      calculate();
    });

    // ── Format currency ──
    function fmt(n) {
      return '$' + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
    }

    function applyRounding(val) {
      if (rounding === 'nearest') return Math.round(val);
      if (rounding === 'up')      return Math.ceil(val);
      return val;
    }

    // ── Calculate ──
    function calculate() {
      const billRaw   = parseFloat(document.getElementById('billAmount').value) || 0;
      const people    = Math.max(1, parseInt(document.getElementById('numPeople').value) || 1);
      const preTax    = document.getElementById('preTaxToggle').checked;
      const taxRatePct= parseFloat(document.getElementById('taxRate').value) || 0;

      let tipBase = billRaw;
      if (preTax && taxRatePct > 0) {
        // pre-tax: bill without tax = billRaw / (1 + taxRate)
        tipBase = billRaw / (1 + taxRatePct / 100);
      }

      const tipAmt   = tipBase * (tipPct / 100);
      const total    = billRaw + tipAmt;
      const perPerson      = applyRounding(total / people);
      const tipPerPerson   = applyRounding(tipAmt / people);

      document.getElementById('resTipAmount').textContent    = fmt(tipAmt);
      document.getElementById('resTipPct').textContent       = tipPct + '% tip' + (preTax && taxRatePct > 0 ? ' (pre-tax)' : '');
      document.getElementById('resTipPerPerson').textContent = fmt(tipPerPerson);
      document.getElementById('resPeopleCount').textContent  = people === 1 ? '1 person' : people + ' people';
      document.getElementById('resTotalBill').textContent    = fmt(total);
      document.getElementById('resTotalSub').textContent     = billRaw > 0 ? 'Bill ' + fmt(billRaw) + ' + Tip ' + fmt(tipAmt) : '';

      const splitDetail = document.getElementById('splitDetail');
      if (people > 1) {
        splitDetail.style.display = 'grid';
        document.getElementById('resPerPerson').textContent    = fmt(perPerson);
        document.getElementById('resPerPersonTip').textContent = fmt(tipPerPerson);
      } else {
        splitDetail.style.display = 'none';
      }
    }

    // Initial render
    calculate();

    // ── Example loader ──
    function loadTipExample(key) {
      const examples = {
        lunch:  { bill: 25,  tip: 20, people: 1 },
        dinner: { bill: 80,  tip: 18, people: 2 },
        group:  { bill: 200, tip: 20, people: 4 }
      };
      const ex = examples[key];
      if (!ex) return;
      document.getElementById('billAmount').value = ex.bill;
      document.getElementById('numPeople').value = ex.people;
      document.getElementById('customTip').value = '';
      document.querySelectorAll('.preset-btn').forEach(b => {
        b.classList.toggle('active', parseFloat(b.dataset.tip) === ex.tip);
      });
      tipPct = ex.tip;
      calculate();
      gtag('event', 'example_loaded', { tool: 'tip-calculator', example: key });
    }

    // ── Share ──
    function shareThis() {
      const shareData = {
        title: 'Tip Calculator',
        text: 'Calculate tips and split bills instantly — free browser tool.',
        url: 'https://lists2.roe3.org/tip-calculator.html'
      };
      if (navigator.share) {
        navigator.share(shareData).catch(() => {});
      } else {
        navigator.clipboard.writeText(shareData.url).then(() => {
          const btn = document.getElementById('shareBtn');
          const orig = btn.innerHTML;
          btn.innerHTML = '&#10003; Link copied';
          setTimeout(() => { btn.innerHTML = orig; }, 1800);
        });
      }
    }
  </script>
</body>
</html>