Files
malenia-frontend/.ref/account.html
2026-08-10 12:01:03 +07:00

922 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MaleniaVPN · Account</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Tenor+Sans&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #faf7f2;
--surface: #f5f0e8;
--fg: #1a1610;
--muted: #3d3428;
--accent: #c9a84c;
--font-display: 'Cormorant Garamond', Georgia, 'Times New Roman', serif;
--font-body: 'Tenor Sans', 'Helvetica Neue', Arial, sans-serif;
--radius: 8px;
--baseline: 8px;
--hairline: rgba(26, 22, 16, 0.10);
--hairline-strong: rgba(26, 22, 16, 0.18);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100vh;
}
/* Grain overlay */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
opacity: 0.035;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Custom cursor */
.cursor-dot, .cursor-ring {
position: fixed;
top: 0; left: 0;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
transition: opacity 0.2s;
}
.cursor-dot {
width: 5px; height: 5px;
background: var(--fg);
border-radius: 50%;
}
.cursor-ring {
width: 30px; height: 30px;
border: 1px solid var(--muted);
border-radius: 50%;
opacity: 0.4;
transition: width 0.25s, height 0.25s, border-color 0.25s, opacity 0.25s, transform 0.08s linear;
}
.cursor-ring.is-active {
width: 52px; height: 52px;
border-color: var(--accent);
opacity: 0.9;
}
@media (hover: none), (pointer: coarse) {
.cursor-dot, .cursor-ring { display: none; }
* { cursor: auto !important; }
}
@media (hover: hover) and (pointer: fine) {
body, button, a, [data-cursor] { cursor: none; }
}
/* Layout */
.shell {
max-width: 1080px;
margin: 0 auto;
padding: 64px 32px 96px;
}
/* Topbar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 32px;
border-bottom: 1px solid var(--hairline);
}
.logo {
font-family: var(--font-display);
font-size: 22px;
font-weight: 500;
letter-spacing: 0.35em;
text-transform: uppercase;
color: var(--fg);
}
.logo .e { color: var(--accent); }
.nav {
display: flex;
gap: 28px;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
}
.nav a {
color: inherit;
text-decoration: none;
position: relative;
padding: 4px 0;
transition: color 0.3s;
}
.nav a:hover { color: var(--fg); }
.nav a.is-active::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: -2px;
height: 1px;
background: var(--accent);
}
/* Hero / identity */
.hero {
padding: 56px 0 48px;
text-align: center;
border-bottom: 1px solid var(--hairline);
}
.kicker {
font-size: 11px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 20px;
}
.hero-title {
font-family: var(--font-display);
font-weight: 500;
font-size: 44px;
line-height: 1.15;
letter-spacing: -0.01em;
color: var(--fg);
}
.hero-sub {
margin-top: 14px;
font-size: 14px;
color: var(--muted);
letter-spacing: 0.02em;
}
/* Profile stat strip — graphics-led */
.profile {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid var(--hairline);
}
.stat {
padding: 40px 24px;
border-right: 1px solid var(--hairline);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
min-height: 168px;
justify-content: center;
}
.stat:last-child { border-right: none; }
.stat-icon {
width: 34px; height: 34px;
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
transition: color 0.3s;
}
.stat:hover .stat-icon { color: var(--accent); }
.stat-label {
font-size: 10px;
letter-spacing: 0.26em;
text-transform: uppercase;
color: var(--muted);
}
.stat-value {
font-family: var(--font-display);
font-weight: 500;
font-size: 26px;
line-height: 1.1;
color: var(--fg);
letter-spacing: -0.01em;
word-break: break-word;
}
.stat-value.is-mono { font-family: var(--font-body); font-size: 15px; letter-spacing: 0.04em; }
.stat-value.is-empty {
color: var(--muted);
opacity: 0.45;
font-family: var(--font-body);
font-size: 13px;
font-style: italic;
letter-spacing: 0.02em;
}
.stat-value .accent { color: var(--accent); }
/* Subscription access link */
.subscription-link {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 24px;
padding: 20px 24px;
border-bottom: 1px solid var(--hairline);
background: color-mix(in srgb, var(--surface) 86%, var(--accent) 14%);
}
.subscription-emblem {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--accent);
border-radius: 50%;
color: var(--fg);
}
.subscription-emblem svg { width: 18px; height: 18px; }
.subscription-copy { min-width: 0; }
.subscription-label {
margin-bottom: 4px;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
}
.subscription-url {
display: block;
overflow: hidden;
color: var(--fg);
font-size: 13px;
letter-spacing: 0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
.subscription-url strong {
color: var(--fg);
font-weight: 400;
}
.copy-link {
appearance: none;
min-height: 44px;
padding: 0 16px;
border: 1px solid var(--fg);
border-radius: var(--radius);
background: var(--fg);
color: var(--bg);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
transition: background 0.25s, color 0.25s, border-color 0.25s, transform 0.15s;
}
.copy-link:hover { background: var(--accent); color: var(--fg); border-color: var(--accent); }
.copy-link:active { transform: translateY(1px); }
.copy-link:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.35);
}
/* Devices section */
.devices-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 48px 0 24px;
}
.devices-title {
font-family: var(--font-display);
font-weight: 500;
font-size: 28px;
letter-spacing: -0.01em;
}
.devices-count {
font-size: 11px;
letter-spacing: 0.26em;
text-transform: uppercase;
color: var(--muted);
}
.devices-count strong {
color: var(--fg);
font-weight: 400;
font-family: var(--font-display);
font-size: 16px;
margin-right: 4px;
letter-spacing: 0;
}
.device-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1px;
background: var(--hairline);
border: 1px solid var(--hairline);
border-radius: var(--radius);
overflow: hidden;
}
.device {
background: var(--surface);
padding: 28px 28px 24px;
display: flex;
align-items: flex-start;
gap: 22px;
position: relative;
transition: background 0.3s;
}
.device:hover { background: color-mix(in srgb, var(--surface) 80%, var(--fg) 4%); }
.device-glyph {
flex: 0 0 auto;
width: 48px; height: 48px;
border: 1px solid var(--hairline-strong);
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
color: var(--fg);
background: var(--bg);
}
.device-glyph svg { width: 22px; height: 22px; }
.device-meta {
flex: 1 1 auto;
min-width: 0;
}
.device-model {
font-family: var(--font-display);
font-weight: 500;
font-size: 20px;
letter-spacing: -0.01em;
line-height: 1.2;
color: var(--fg);
margin-bottom: 6px;
}
.device-tags {
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.device-tags .tag-client {
color: var(--fg);
}
.device-tags .sep {
width: 1px;
background: var(--hairline-strong);
align-self: stretch;
}
.device-delete {
flex: 0 0 auto;
appearance: none;
background: transparent;
border: 1px solid var(--hairline-strong);
color: var(--muted);
width: 36px; height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.25s, color 0.25s, background 0.25s, transform 0.25s;
}
.device-delete:hover {
border-color: var(--fg);
color: var(--fg);
background: var(--bg);
}
.device-delete:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.35);
border-color: var(--accent);
}
.device-delete svg { width: 14px; height: 14px; }
/* Empty devices state */
.devices-empty {
border: 1px solid var(--hairline);
border-radius: var(--radius);
background: var(--surface);
padding: 72px 24px;
text-align: center;
}
.devices-empty-glyph {
width: 44px; height: 44px;
margin: 0 auto 20px;
color: var(--muted);
opacity: 0.55;
}
.devices-empty-title {
font-family: var(--font-display);
font-weight: 500;
font-size: 22px;
color: var(--fg);
margin-bottom: 8px;
}
.devices-empty-sub {
font-size: 13px;
color: var(--muted);
letter-spacing: 0.02em;
}
/* Footer rule */
.foot {
margin-top: 64px;
padding-top: 28px;
border-top: 1px solid var(--hairline);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
}
/* Verification modal */
.modal-veil {
position: fixed;
inset: 0;
background: rgba(26, 22, 16, 0.32);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s;
padding: 24px;
}
.modal-veil.is-open {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--bg);
border: 1px solid var(--hairline-strong);
border-radius: var(--radius);
max-width: 420px;
width: 100%;
padding: 36px 32px 28px;
text-align: center;
transform: translateY(8px);
transition: transform 0.3s cubic-bezier(.2,.7,.2,1);
box-shadow: 0 24px 60px -20px rgba(26, 22, 16, 0.35);
}
.modal-veil.is-open .modal { transform: translateY(0); }
.modal-glyph {
width: 52px; height: 52px;
margin: 0 auto 20px;
border: 1px solid var(--accent);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
}
.modal-glyph svg { width: 22px; height: 22px; }
.modal-kicker {
font-size: 10px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 10px;
}
.modal-title {
font-family: var(--font-display);
font-weight: 500;
font-size: 24px;
line-height: 1.25;
color: var(--fg);
margin-bottom: 12px;
}
.modal-body {
font-size: 14px;
color: var(--muted);
line-height: 1.55;
margin-bottom: 26px;
}
.modal-body strong {
color: var(--fg);
font-weight: 400;
font-family: var(--font-display);
font-size: 16px;
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.btn {
appearance: none;
font-family: var(--font-body);
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
padding: 13px 22px;
border-radius: var(--radius);
border: 1px solid var(--hairline-strong);
background: transparent;
color: var(--fg);
transition: background 0.25s, color 0.25s, border-color 0.25s, transform 0.15s;
min-height: 44px;
}
.btn:hover { background: var(--surface); }
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.35);
border-color: var(--accent);
}
.btn-primary {
background: var(--fg);
color: var(--bg);
border-color: var(--fg);
}
.btn-primary:hover {
background: var(--accent);
color: var(--fg);
border-color: var(--accent);
}
.btn-danger:hover {
background: var(--fg);
color: var(--bg);
border-color: var(--fg);
}
.btn:active { transform: translateY(1px); }
/* Fade-up */
.reveal {
opacity: 0;
transform: translateY(14px);
transition: opacity 0.7s cubic-bezier(.2,.7,.2,1), transform 0.7s cubic-bezier(.2,.7,.2,1);
}
.reveal.is-in { opacity: 1; transform: translateY(0); }
/* Toast */
.toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translate(-50%, 16px);
background: var(--fg);
color: var(--bg);
padding: 13px 22px;
border-radius: var(--radius);
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
z-index: 10001;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s, transform 0.3s;
box-shadow: 0 12px 30px -10px rgba(26, 22, 16, 0.4);
}
.toast.is-show { opacity: 1; transform: translate(-50%, 0); }
/* Responsive */
@media (max-width: 860px) {
.profile { grid-template-columns: repeat(2, 1fr); }
.stat:nth-child(2) { border-right: none; }
.stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--hairline); }
.device-grid { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
.shell { padding: 40px 20px 64px; }
.topbar { padding-bottom: 24px; }
.nav { gap: 18px; }
.nav a { font-size: 10px; }
.hero { padding: 40px 0 36px; }
.hero-title { font-size: 34px; }
.profile { grid-template-columns: 1fr; }
.stat { border-right: none; border-bottom: 1px solid var(--hairline); min-height: 132px; padding: 32px 20px; }
.stat:last-child { border-bottom: none; }
.subscription-link { grid-template-columns: auto minmax(0, 1fr); gap: 16px; padding: 20px; }
.subscription-url { font-size: 12px; }
.copy-link { grid-column: 1 / -1; width: 100%; }
.devices-head { padding: 40px 0 20px; }
.device { padding: 22px 20px; gap: 16px; }
.device-model { font-size: 18px; }
.device-delete { width: 34px; height: 34px; }
.foot { flex-direction: column; gap: 10px; text-align: center; }
.modal { padding: 28px 24px 24px; }
.modal-title { font-size: 21px; }
}
</style>
</head>
<body>
<div class="cursor-ring" aria-hidden="true"></div>
<div class="cursor-dot" aria-hidden="true"></div>
<main class="shell">
<!-- Topbar -->
<header class="topbar" data-od-id="topbar">
<div class="logo" data-od-id="brand">Mal<span class="e">e</span>nia</div>
<nav class="nav" data-od-id="primary-nav">
<a href="#" class="is-active">Account</a>
<a href="#">Devices</a>
<a href="#">Billing</a>
<a href="#">Support</a>
</nav>
</header>
<!-- Hero identity -->
<section class="hero reveal" data-od-id="account-hero">
<div class="kicker">Your account</div>
<h1 class="hero-title">Eleanor Ashworth</h1>
<p class="hero-sub">Member since March 2023 · Plan: Solitude</p>
</section>
<!-- Profile stat strip -->
<section class="profile reveal" data-od-id="profile-stats" aria-label="Account summary">
<!-- Username -->
<div class="stat" data-od-id="stat-username">
<div class="stat-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" width="100%" height="100%">
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-3.6 3.6-6 8-6s8 2.4 8 6"/>
</svg>
</div>
<div class="stat-label">Username</div>
<div class="stat-value">e.ashworth</div>
</div>
<!-- Telegram ID -->
<div class="stat" data-od-id="stat-telegram">
<div class="stat-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" width="100%" height="100%">
<path d="M21 4L3 11l5 2 2 6 3-4 4 3 4-14z"/>
<path d="M8 13l8-6"/>
</svg>
</div>
<div class="stat-label">Telegram ID</div>
<div class="stat-value is-mono">@eashworth</div>
</div>
<!-- Device count -->
<div class="stat" data-od-id="stat-device-count">
<div class="stat-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" width="100%" height="100%">
<rect x="3" y="4" width="18" height="12" rx="1.5"/>
<path d="M9 20h6M12 16v4"/>
</svg>
</div>
<div class="stat-label">Devices linked</div>
<div class="stat-value"><span id="device-count">4</span> <span class="accent">/</span> <span style="font-family:var(--font-body); font-size:14px; color:var(--muted); letter-spacing:0.04em">of 6</span></div>
</div>
<!-- Subscription expiry -->
<div class="stat" data-od-id="stat-expiry">
<div class="stat-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" width="100%" height="100%">
<rect x="3" y="5" width="18" height="16" rx="1.5"/>
<path d="M3 9h18M8 3v4M16 3v4"/>
</svg>
</div>
<div class="stat-label">Renews</div>
<div class="stat-value">14 Mar 2026</div>
</div>
</section>
<section class="subscription-link reveal" data-od-id="subscription-link" aria-label="Subscription link">
<div class="subscription-emblem" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1"/>
<path d="M14 11a5 5 0 0 0-7.1-.1l-2 2A5 5 0 0 0 12 20l1.1-1.1"/>
</svg>
</div>
<div class="subscription-copy">
<div class="subscription-label">Subscription link</div>
<span class="subscription-url" id="subscription-url">https://malenia.network/sub/<strong>mle_7f2a9c</strong></span>
</div>
<button class="copy-link" id="copy-subscription-link" data-od-id="copy-subscription-link" data-cursor type="button">Copy link</button>
</section>
<!-- Devices -->
<section data-od-id="devices-section">
<div class="devices-head reveal">
<h2 class="devices-title">Connected devices</h2>
<div class="devices-count" data-od-id="devices-count"><strong id="devices-count-num">4</strong>active</div>
</div>
<div class="device-grid reveal" data-od-id="device-grid" id="device-grid">
<!-- devices injected by JS -->
</div>
</section>
<!-- Footer -->
<footer class="foot" data-od-id="footer">
<span>MaleniaVPN · Solitude plan</span>
<span>Sign out</span>
</footer>
</main>
<!-- Verification modal -->
<div class="modal-veil" id="modal-veil" data-od-id="delete-verify-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" aria-hidden="true">
<div class="modal" role="document">
<div class="modal-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14"/>
</svg>
</div>
<div class="modal-kicker">Confirm removal</div>
<h3 class="modal-title" id="modal-title">Remove this device?</h3>
<p class="modal-body">Disconnecting <strong id="modal-device-name">—</strong> will end its VPN session immediately. You can reconnect it later from the device.</p>
<div class="modal-actions">
<button class="btn" id="modal-cancel" data-od-id="modal-cancel">Keep it</button>
<button class="btn btn-danger" id="modal-confirm" data-od-id="modal-confirm">Remove</button>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
(function() {
// ----- Device data -----
const devices = [
{ id: 'd1', os: 'macos', model: 'MacBook Pro 14"', client: 'Malenia 2.4', joined: 'Joined 2d ago' },
{ id: 'd2', os: 'ios', model: 'iPhone 15 Pro', client: 'Malenia 2.4', joined: 'Joined 11d ago' },
{ id: 'd3', os: 'android', model: 'Pixel 8', client: 'OpenVPN', joined: 'Joined 28d ago' },
{ id: 'd4', os: 'windows', model: 'Surface Laptop 5', client: 'Malenia 2.4', joined: 'Joined 3mo ago' }
];
// ----- OS glyphs (monoline SVG) -----
const glyphs = {
macos: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="13" rx="1.5"/><path d="M1 20h22M9 20l1-3h4l1 3"/></svg>',
ios: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 5.5h2"/></svg>',
android: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 14a7 7 0 0 1 14 0v5H5z"/><path d="M5 14L3 12M19 14l2-2M8 9l-1.5-2M16 9l1.5-2M9 8.5h6"/></svg>',
windows: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5l8-1v8H3zM12 4l9-1v10h-9zM3 13h8v6l-8-1zM12 13h9v8l-9-1z"/></svg>',
linux: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="13" rx="6" ry="7"/><path d="M9 9.5l1 1M15 9.5l-1 1M10 17h4"/></svg>'
};
const osLabels = { macos: 'macOS', ios: 'iOS', android: 'Android', windows: 'Windows', linux: 'Linux' };
// ----- Render -----
const grid = document.getElementById('device-grid');
function renderDevices() {
grid.innerHTML = '';
if (devices.length === 0) {
const empty = document.createElement('div');
empty.className = 'devices-empty';
empty.setAttribute('data-od-id', 'devices-empty');
empty.innerHTML = `
<div class="devices-empty-glyph">${glyphs.macos}</div>
<div class="devices-empty-title">No devices linked</div>
<div class="devices-empty-sub">Install MaleniaVPN on a device to connect it to your account.</div>
`;
grid.appendChild(empty);
} else {
devices.forEach((d, i) => {
const card = document.createElement('article');
card.className = 'device reveal';
card.setAttribute('data-od-id', 'device-' + d.id);
card.innerHTML = `
<div class="device-glyph" aria-hidden="true">${glyphs[d.os] || glyphs.linux}</div>
<div class="device-meta">
<h3 class="device-model">${d.model}</h3>
<div class="device-tags">
<span>${osLabels[d.os] || d.os}</span>
<span class="sep"></span>
<span class="tag-client">${d.client}</span>
<span class="sep"></span>
<span>${d.joined}</span>
</div>
</div>
<button class="device-delete" data-od-id="device-delete-${d.id}" data-cursor aria-label="Remove ${d.model}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14"/></svg>
</button>
`;
grid.appendChild(card);
requestAnimationFrame(() => {
setTimeout(() => card.classList.add('is-in'), 40 * i);
});
});
}
document.getElementById('device-count').textContent = devices.length;
document.getElementById('devices-count-num').textContent = devices.length;
bindDeleteButtons();
}
// ----- Delete flow -----
const veil = document.getElementById('modal-veil');
const modalName = document.getElementById('modal-device-name');
const cancelBtn = document.getElementById('modal-cancel');
const confirmBtn = document.getElementById('modal-confirm');
const toast = document.getElementById('toast');
let pendingId = null;
function bindDeleteButtons() {
grid.querySelectorAll('.device-delete').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-od-id').replace('device-delete-', '');
const d = devices.find(x => x.id === id);
if (!d) return;
pendingId = id;
modalName.textContent = d.model;
openModal();
});
});
}
function openModal() {
veil.classList.add('is-open');
veil.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
setTimeout(() => cancelBtn.focus(), 60);
}
function closeModal() {
veil.classList.remove('is-open');
veil.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
pendingId = null;
}
function showToast(msg) {
toast.textContent = msg;
toast.classList.add('is-show');
setTimeout(() => toast.classList.remove('is-show'), 2200);
}
// ----- Subscription link -----
const subscriptionUrl = document.getElementById('subscription-url');
const copySubscriptionLink = document.getElementById('copy-subscription-link');
copySubscriptionLink.addEventListener('click', async () => {
const link = subscriptionUrl.textContent.trim();
try {
await navigator.clipboard.writeText(link);
showToast('Subscription link copied');
} catch (error) {
const fallback = document.createElement('textarea');
fallback.value = link;
fallback.setAttribute('readonly', '');
fallback.style.position = 'fixed';
fallback.style.opacity = '0';
document.body.appendChild(fallback);
fallback.select();
document.execCommand('copy');
fallback.remove();
showToast('Subscription link copied');
}
});
cancelBtn.addEventListener('click', closeModal);
confirmBtn.addEventListener('click', () => {
if (!pendingId) return;
const idx = devices.findIndex(x => x.id === pendingId);
if (idx > -1) {
const removed = devices.splice(idx, 1)[0];
renderDevices();
showToast(removed.model + ' disconnected');
}
closeModal();
});
veil.addEventListener('click', (e) => { if (e.target === veil) closeModal(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && veil.classList.contains('is-open')) closeModal();
});
// ----- Reveal observer -----
const io = new IntersectionObserver((entries) => {
entries.forEach(en => {
if (en.isIntersecting) {
en.target.classList.add('is-in');
io.unobserve(en.target);
}
});
}, { threshold: 0.12 });
document.querySelectorAll('.reveal').forEach((el, i) => {
el.style.transitionDelay = (i * 80) + 'ms';
io.observe(el);
});
// ----- Custom cursor -----
const dot = document.querySelector('.cursor-dot');
const ring = document.querySelector('.cursor-ring');
let mx = 0, my = 0, rx = 0, ry = 0;
const fine = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
if (fine) {
window.addEventListener('mousemove', (e) => {
mx = e.clientX; my = e.clientY;
dot.style.left = mx + 'px'; dot.style.top = my + 'px';
});
function tick() {
rx += (mx - rx) * 0.18; ry += (my - ry) * 0.18;
ring.style.left = rx + 'px'; ring.style.top = ry + 'px';
requestAnimationFrame(tick);
}
tick();
document.addEventListener('mouseover', (e) => {
const t = e.target.closest('a, button, [data-cursor]');
ring.classList.toggle('is-active', !!t);
});
} else {
dot.style.display = 'none'; ring.style.display = 'none';
}
// ----- Init -----
renderDevices();
})();
</script>
</body>
</html>