summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-29 20:19:34 +0300
committerPaul Buetow <paul@buetow.org>2026-04-29 20:19:34 +0300
commit4a70c0ea083306cb79d0fa325d331ffa4333154a (patch)
tree84df7e2f781974ef45ac8c34f1a59e80fdd5c41d
parentcf30414c2a0696cc75c615f4da66ea85d0522c42 (diff)
feat: create web PWA frontend
-rw-r--r--...129
-rw-r--r--web/bootstrap.html25
-rw-r--r--web/css/components.css134
-rw-r--r--web/css/layout.css276
-rw-r--r--web/css/login.css27
-rw-r--r--web/css/player.css132
-rw-r--r--web/css/theme.css105
-rw-r--r--web/favicon.svg1
-rw-r--r--web/index.html121
-rw-r--r--web/js/admin.js101
-rw-r--r--web/js/api.js66
-rw-r--r--web/js/app.js311
-rw-r--r--web/js/keyboard.js28
-rw-r--r--web/js/notes.js45
-rw-r--r--web/js/player.js223
-rw-r--r--web/js/pwa.js7
-rw-r--r--web/js/search.js35
-rw-r--r--web/js/selection.js43
-rw-r--r--web/js/shuffle.js24
-rw-r--r--web/js/state.js11
-rw-r--r--web/js/themes.js20
-rw-r--r--web/login.html21
-rw-r--r--web/manifest.json11
-rw-r--r--web/sw.js41
24 files changed, 1932 insertions, 5 deletions
diff --git a/... b/...
new file mode 100644
index 0000000..f16c0de
--- /dev/null
+++ b/...
@@ -0,0 +1,129 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>KISS Media Player</title>
+<link rel="manifest" href="/manifest.json">
+<link rel="stylesheet" href="/css/theme.css">
+<link rel="stylesheet" href="/css/layout.css">
+<link rel="stylesheet" href="/css/components.css">
+<link rel="stylesheet" href="/css/player.css">
+<script type="module" src="/js/app.js"></script>
+</head>
+<body>
+<header class="site-header" role="banner">
+ <a href="/" class="logo">KISS Media</a>
+ <div class="search-bar" role="search">
+ <span aria-hidden="true">🔎</span>
+ <input id="search-input" type="search" placeholder="Search… (press /)" autocomplete="off" aria-label="Search media">
+ <button id="search-clear" class="icon-btn btn-sm" title="Clear">✕</button>
+ </div>
+ <div class="spacer"></div>
+ <button id="shuffle-toggle" class="icon-btn" title="Shuffle (r)">🔀</button>
+ <button id="admin-toggle" class="icon-btn hidden" title="Admin panel (a)">⚙</button>
+ <button id="theme-toggle" class="icon-btn" title="Toggle theme">☀</button>
+ <span id="user-name" class="text-secondary" style="font-size:0.85rem;"></span>
+ <button id="logout-btn" class="btn btn-ghost btn-sm">Log out</button>
+ <button id="menu-btn" class="icon-btn" title="Menu" aria-label="Open sets">☰</button>
+</header>
+
+<nav id="sidebar" class="sidebar" aria-label="Sets">
+ <h2>Sets</h2>
+ <div id="set-list"></div>
+</nav>
+
+<div class="page" role="main">
+ <div class="main-content">
+ <div class="toolbar" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
+ <select id="filter-type" aria-label="Filter by type">
+ <option value="">All</option>
+ <option value="video">Video</option>
+ <option value="audio">Audio</option>
+ </select>
+ <button id="filter-favorites" class="btn btn-ghost btn-sm">❤ Favorites</button>
+ <div class="spacer"></div>
+ <span id="result-count" class="text-muted" style="font-size:0.8rem;"></span>
+ </div>
+ <div id="media-grid" class="media-grid" tabindex="0" aria-label="Media grid"></div>
+ </div>
+</div>
+
+<aside id="player" class="player open" aria-label="Player">
+ <div class="stage">
+ <video id="media-video" playsinline controlslist="nodownload"></video>
+ <audio id="media-audio" style="display:none;"></audio>
+ <div id="big-play" class="big-play" role="button" aria-label="Play" tabindex="0">
+ <div class="play-icon">▶</div>
+ </div>
+ </div>
+ <div class="controls">
+ <button id="btn-play" class="icon-btn" aria-label="Play/Pause (Space/p)">▶</button>
+ <div class="time" id="time-elapsed">0:00</div>
+ <div id="progress-track" class="progress-track" tabindex="0" aria-label="Seek">
+ <div id="progress-fill" class="progress-fill"></div>
+ <div id="progress-thumb" class="progress-thumb" aria-hidden="true"></div>
+ </div>
+ <div class="time" id="time-total">0:00</div>
+ <button id="btn-prev" class="icon-btn" aria-label="Previous">⏮</button>
+ <button id="btn-next" class="icon-btn" aria-label="Next">⏭</button>
+ <div class="volume-control">
+ <button id="btn-mute" class="icon-btn" aria-label="Mute">🔊</button>
+ <input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1" class="volume-slider" aria-label="Volume">
+ </div>
+ <button id="btn-fullscreen" class="icon-btn" aria-label="Fullscreen (f)">⛶</button>
+ </div>
+</aside>
+
+<footer class="toast" id="toast" role="status" aria-live="polite"></footer>
+
+<!-- Notes Modal -->
+<div id="notes-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="notes-title">
+ <div class="modal">
+ <div class="modal-header">
+ <h3 id="notes-title">Notes</h3>
+ <div class="spacer"></div>
+ <button id="notes-close" class="icon-btn" aria-label="Close">✕</button>
+ </div>
+ <textarea id="notes-textarea" rows="6" placeholder="Add your notes…"></textarea>
+ <div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:0.5rem;">
+ <button id="notes-delete" class="btn btn-danger btn-sm">Delete</button>
+ <button id="notes-save" class="btn btn-primary btn-sm">Save</button>
+ </div>
+ </div>
+</div>
+
+<!-- Admin Modal -->
+<div id="admin-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="admin-title">
+ <div class="modal" style="width:min(56rem,96vw);">
+ <div class="modal-header">
+ <h3 id="admin-title">Admin</h3>
+ <div class="spacer"></div>
+ <button id="admin-close" class="icon-btn" aria-label="Close">✕</button>
+ </div>
+ <div style="display:flex;gap:1rem;flex-wrap:wrap;">
+ <section style="flex:1 1 18rem;">
+ <h4 style="margin:0.25rem 0;font-size:0.9rem;">Users</h4>
+ <div id="admin-users"></div>
+ <form id="admin-user-form" style="margin-top:0.5rem;">
+ <input name="username" placeholder="Username" required autocomplete="off">
+ <input type="password" name="password" placeholder="Password" required autocomplete="new-password">
+ <label style="display:flex;align-items:center;gap:0.35rem;font-size:0.8rem;">
+ <input type="checkbox" name="is_admin"> Admin
+ </label>
+ <button type="submit" class="btn btn-primary btn-sm" style="margin-top:0.25rem;">Add user</button>
+ </form>
+ </section>
+ <section style="flex:1 1 18rem;">
+ <h4 style="margin:0.25rem 0;font-size:0.9rem;">Permissions</h4>
+ <div id="admin-permissions"></div>
+ </section>
+ </div>
+ <div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
+ <button id="admin-rescan" class="btn btn-primary btn-sm">Trigger rescan</button>
+ <button id="admin-trash" class="btn btn-danger btn-sm">View trash</button>
+ </div>
+ </div>
+</div>
+</body>
+</html>
diff --git a/web/bootstrap.html b/web/bootstrap.html
index 378b595..440a55f 100644
--- a/web/bootstrap.html
+++ b/web/bootstrap.html
@@ -2,9 +2,30 @@
<html lang="en">
<head>
<meta charset="UTF-8">
-<title>Bootstrap - KISS Media Player</title>
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Bootstrap — KISS Media Player</title>
+<link rel="stylesheet" href="/css/theme.css">
+<link rel="stylesheet" href="/css/components.css">
+<link rel="stylesheet" href="/css/login.css">
</head>
<body>
-<h1>First Admin Setup</h1>
+<div class="login-container" role="main">
+ <h1>Create Admin Account</h1>
+ <p class="subtitle">One-time setup. This form is available only while no users exist.</p>
+ <form id="bootstrap-form" class="login-form" novalidate>
+ <label for="username">Username</label>
+ <input type="text" id="username" name="username" autocomplete="username" required autofocus>
+
+ <label for="password">Password</label>
+ <input type="password" id="password" name="password" autocomplete="new-password" required minlength="6">
+
+ <label for="password-confirm">Confirm Password</label>
+ <input type="password" id="password-confirm" name="password-confirm" autocomplete="new-password" required minlength="6">
+
+ <button type="submit" class="btn btn-primary">Create Admin</button>
+ <p id="bootstrap-error" class="error-message" role="alert" aria-live="polite"></p>
+ </form>
+</div>
+<script type="module" src="/js/app.js"></script>
</body>
</html>
diff --git a/web/css/components.css b/web/css/components.css
new file mode 100644
index 0000000..6d2f074
--- /dev/null
+++ b/web/css/components.css
@@ -0,0 +1,134 @@
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ padding: 0.5rem 0.85rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid transparent;
+ font-size: 0.85rem;
+ font-weight: 600;
+ line-height: 1.2;
+ cursor: pointer;
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ transition: background var(--transition-fast), border-color var(--transition-fast), transform 80ms ease;
+}
+.btn:hover { background: var(--bg-surface-hover); }
+.btn:active { transform: translateY(1px); }
+.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+
+.btn-primary {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent-active);
+}
+.btn-primary:hover { background: var(--accent-hover); }
+.btn-danger {
+ background: var(--danger);
+ color: #fff;
+ border-color: #b91c1c;
+}
+.btn-danger:hover { background: #e04e4e; }
+.btn-ghost {
+ background: transparent;
+ color: var(--text-secondary);
+ border-color: var(--border);
+}
+.btn-ghost:hover { color: var(--text-primary); background: var(--bg-surface-hover); }
+.btn-sm { padding: 0.3rem 0.55rem; font-size: 0.78rem; }
+.btn-lg { padding: 0.65rem 1.1rem; font-size: 0.95rem; }
+
+/* Icon buttons */
+.icon-btn {
+ width: 2.25rem;
+ height: 2.25rem;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.05rem;
+ line-height: 1;
+ padding: 0;
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+.icon-btn:hover { background: var(--bg-surface-hover); color: var(--text-primary); }
+.icon-btn.active { color: var(--accent); background: var(--accent-soft); }
+
+/* Forms */
+input, textarea, select {
+ font: inherit;
+ color: var(--text-primary);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 0.5rem 0.65rem;
+ width: 100%;
+}
+input:focus, textarea:focus, select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+label {
+ display: block;
+ font-size: 0.8rem;
+ font-weight: 600;
+ margin: 0.35rem 0 0.2rem;
+ color: var(--text-secondary);
+}
+
+/* Error message */
+.error-message {
+ color: var(--danger);
+ font-size: 0.85rem;
+ min-height: 1.25rem;
+ margin: 0.35rem 0 0;
+}
+
+/* Search bar */
+.search-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 0.25rem 0.6rem;
+ max-width: 22rem;
+ width: 100%;
+}
+.search-bar input {
+ background: transparent;
+ border: none;
+ padding: 0.35rem 0.2rem;
+ flex: 1 1 auto;
+}
+.search-bar input:focus { box-shadow: none; }
+
+/* Toast */
+.toast {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ padding: 0.65rem 0.9rem;
+ z-index: 110;
+ font-size: 0.85rem;
+ transform: translateY(120%);
+ opacity: 0;
+ transition: transform var(--transition-base), opacity var(--transition-base);
+}
+.toast.show { transform: translateY(0); opacity: 1; }
+.toast.info { border-left: 4px solid var(--accent); }
+.toast.success { border-left: 4px solid var(--success); }
+.toast.error { border-left: 4px solid var(--danger); }
diff --git a/web/css/layout.css b/web/css/layout.css
new file mode 100644
index 0000000..9f51008
--- /dev/null
+++ b/web/css/layout.css
@@ -0,0 +1,276 @@
+/* Reset & base */
+*, *::before, *::after { box-sizing: border-box; }
+html, body {
+ height: 100%;
+}
+body {
+ margin: 0;
+ font-family: var(--font-sans);
+ background: var(--bg-body);
+ color: var(--text-primary);
+ line-height: 1.45;
+}
+
+/* Header */
+.site-header {
+ position: fixed;
+ inset: 0 0 auto 0;
+ height: var(--header-height);
+ background: var(--bg-surface);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ gap: var(--gap);
+ padding: 0 1rem;
+ z-index: 50;
+}
+.site-header .logo {
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: var(--text-primary);
+ text-decoration: none;
+}
+.site-header .spacer { flex: 1 1 auto; }
+
+/* Sidebar */
+.sidebar {
+ position: fixed;
+ inset: var(--header-height) auto 0 0;
+ width: var(--sidebar-width);
+ background: var(--bg-surface);
+ border-right: 1px solid var(--border);
+ overflow: auto;
+ padding: 0.75rem 0.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ z-index: 40;
+}
+.sidebar h2 {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-muted);
+ margin: 0.5rem 0.5rem 0.25rem;
+ padding-bottom: 0.25rem;
+ border-bottom: 1px solid var(--border);
+}
+.sidebar .set-item {
+ display: block;
+ padding: 0.4rem 0.6rem;
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ text-decoration: none;
+ cursor: pointer;
+ user-select: none;
+}
+.sidebar .set-item:hover,
+.sidebar .set-item:focus-visible {
+ background: var(--bg-surface-hover);
+ color: var(--text-primary);
+ outline: none;
+}
+.sidebar .set-item.active {
+ background: var(--accent-soft);
+ color: var(--accent);
+ font-weight: 600;
+}
+
+/* Main layout */
+.page {
+ padding-top: var(--header-height);
+ padding-left: var(--sidebar-width);
+ min-height: 100vh;
+}
+.main-content {
+ padding: var(--gap);
+ max-width: 100%;
+}
+
+/* Media grid */
+.media-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
+ gap: var(--gap);
+}
+.media-card {
+ position: relative;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ cursor: pointer;
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast);
+}
+.media-card:hover {
+ box-shadow: var(--shadow-md);
+ border-color: var(--border-strong);
+}
+.media-card.selected {
+ border-color: var(--selected-border);
+ background: var(--selected-bg);
+}
+.media-card.playing {
+ border-color: var(--playing-border);
+ background: var(--playing-bg);
+}
+.media-card .thumb-wrap {
+ position: relative;
+ aspect-ratio: 16 / 9;
+ background: var(--bg-elevated);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+.media-card .thumb-wrap img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+.media-card .thumb-wrap .placeholder {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+.media-card .meta {
+ padding: 0.55rem 0.65rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ min-height: 3.5rem;
+}
+.media-card .title {
+ font-size: 0.9rem;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.media-card .subtitle {
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.media-card .badge {
+ position: absolute;
+ top: 0.35rem;
+ right: 0.35rem;
+ font-size: 0.65rem;
+ padding: 0.15rem 0.4rem;
+ border-radius: var(--radius-sm);
+ background: var(--bg-overlay);
+ color: var(--text-primary);
+}
+
+/* Audio row variant */
+.media-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: border-color var(--transition-fast), background var(--transition-fast);
+}
+.media-row:hover {
+ background: var(--bg-surface-hover);
+ border-color: var(--border-strong);
+}
+.media-row.selected {
+ border-color: var(--selected-border);
+ background: var(--selected-bg);
+}
+.media-row.playing {
+ border-color: var(--playing-border);
+ background: var(--playing-bg);
+}
+.media-row .row-icon {
+ color: var(--text-muted);
+ font-size: 1.1rem;
+}
+.media-row .row-body {
+ min-width: 0;
+ flex: 1 1 auto;
+}
+.media-row .row-title {
+ font-size: 0.9rem;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.media-row .row-meta {
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+}
+.media-row .row-duration {
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+
+/* Modal */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: var(--bg-overlay);
+ display: grid;
+ place-items: center;
+ z-index: 100;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity var(--transition-base), visibility var(--transition-base);
+}
+.modal-overlay.open {
+ opacity: 1;
+ visibility: visible;
+}
+.modal {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ width: min(40rem, 92vw);
+ max-height: 86vh;
+ overflow: auto;
+ padding: 1.25rem;
+}
+.modal-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+.modal-header h3 { margin: 0; font-size: 1.1rem; }
+.modal-header .spacer { flex: 1 1 auto; }
+
+/* Responsive */
+@media (max-width: 860px) {
+ .sidebar {
+ position: fixed;
+ inset: var(--header-height) auto auto 0;
+ width: 100%;
+ height: auto;
+ max-height: 70vh;
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ display: none;
+ }
+ .sidebar.open { display: flex; }
+ .page { padding-left: 0; }
+ .media-grid { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); }
+}
+
+@media (max-width: 520px) {
+ .media-grid { grid-template-columns: repeat(2, 1fr); }
+}
+
+/* Utilities */
+.hidden { display: none !important; }
+.overflow-hidden { overflow: hidden; }
+
diff --git a/web/css/login.css b/web/css/login.css
new file mode 100644
index 0000000..a32ebd8
--- /dev/null
+++ b/web/css/login.css
@@ -0,0 +1,27 @@
+/* Login / bootstrap page layout */
+.login-container {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 2rem 1rem;
+ background: var(--bg-body);
+}
+.login-container h1 {
+ margin: 0 0 0.25rem;
+ font-size: 1.5rem;
+}
+.login-container .subtitle {
+ margin: 0 0 1rem;
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+}
+.login-form {
+ width: 100%;
+ max-width: 22rem;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-md);
+ padding: 1.25rem 1.25rem 1rem;
+}
+.login-form .btn { width: 100%; margin-top: 0.5rem; }
diff --git a/web/css/player.css b/web/css/player.css
new file mode 100644
index 0000000..e1c13b4
--- /dev/null
+++ b/web/css/player.css
@@ -0,0 +1,132 @@
+/* Player */
+.player {
+ position: fixed;
+ inset: auto 0 0 var(--sidebar-width);
+ background: var(--player-bg);
+ color: var(--text-primary);
+ border-top: 1px solid var(--border);
+ z-index: 60;
+ transition: inset var(--transition-base);
+}
+.player.is-fullscreen {
+ inset: 0;
+ background: #000;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.player .stage {
+ position: relative;
+ background: #000;
+}
+.player:not(.is-fullscreen) .stage {
+ height: 0;
+ overflow: hidden;
+ transition: height var(--transition-base);
+}
+.player.open .stage {
+ height: clamp(10rem, 28vh, 26rem);
+}
+.player.is-fullscreen .stage {
+ flex: 1 1 auto;
+ height: auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.player video, .player audio {
+ width: 100%;
+ height: 100%;
+ display: block;
+ object-fit: contain;
+}
+.player.is-fullscreen video, .player.is-fullscreen audio {
+ max-height: 100%;
+}
+
+/* Overlay controls */
+.player .controls {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+}
+.player .controls .spacer { flex: 1 1 auto; }
+
+.player .time {
+ font-family: var(--font-mono);
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+/* Progress bar */
+.progress-track {
+ position: relative;
+ flex: 1 1 auto;
+ height: 0.35rem;
+ background: var(--player-progress-bg);
+ border-radius: 999px;
+ cursor: pointer;
+ overflow: hidden;
+}
+.progress-track:hover { height: 0.5rem; }
+.progress-fill {
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 0%;
+ background: var(--player-progress-fill);
+ border-radius: 999px;
+ transition: width 120ms linear;
+}
+.progress-thumb {
+ position: absolute;
+ top: 50%;
+ width: 0.9rem;
+ height: 0.9rem;
+ background: var(--player-thumb);
+ border-radius: 50%;
+ transform: translate(-50%, -50%) scale(0);
+ transition: transform var(--transition-fast);
+ pointer-events: none;
+}
+.progress-track:hover .progress-thumb,
+.progress-track:focus-within .progress-thumb {
+ transform: translate(-50%, -50%) scale(1);
+}
+
+/* Volume */
+.volume-control {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+.volume-slider {
+ width: 5rem;
+ height: 0.25rem;
+ cursor: pointer;
+ accent-color: var(--accent);
+}
+
+/* Big play button */
+.big-play {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ background: rgba(0,0,0,0.35);
+ cursor: pointer;
+ z-index: 5;
+}
+.big-play .play-icon {
+ width: 3.5rem;
+ height: 3.5rem;
+ border-radius: 50%;
+ background: rgba(255,255,255,0.9);
+ color: #000;
+ display: grid;
+ place-items: center;
+ font-size: 1.25rem;
+}
diff --git a/web/css/theme.css b/web/css/theme.css
new file mode 100644
index 0000000..927b3b0
--- /dev/null
+++ b/web/css/theme.css
@@ -0,0 +1,105 @@
+:root {
+ /* Core palette */
+ --bg-body: #0f1117;
+ --bg-surface: #161920;
+ --bg-surface-hover: #1b1f27;
+ --bg-elevated: #1e222c;
+ --bg-overlay: rgba(0,0,0,0.65);
+
+ /* Text */
+ --text-primary: #e6e8ef;
+ --text-secondary: #a3a8b8;
+ --text-muted: #6b7280;
+ --text-inverse: #0b0d12;
+
+ /* Accent */
+ --accent: #5e9eff;
+ --accent-hover: #7cb2ff;
+ --accent-active: #3d8bf7;
+ --accent-soft: rgba(94,158,255,0.18);
+
+ /* Borders */
+ --border: #252a36;
+ --border-strong: #2e3546;
+
+ /* Feedback */
+ --danger: #f25c5c;
+ --danger-soft: rgba(242,92,92,0.18);
+ --success: #3ddc84;
+ --success-soft: rgba(61,220,132,0.18);
+ --warn: #ffb300;
+
+ /* Player */
+ --player-bg: #0b0d12;
+ --player-progress-bg: #2e3546;
+ --player-progress-fill: #5e9eff;
+ --player-thumb: #7cb2ff;
+
+ /* Selection */
+ --selected-bg: rgba(94,158,255,0.25);
+ --selected-border: #5e9eff;
+ --playing-bg: rgba(61,220,132,0.18);
+ --playing-border: #3ddc84;
+
+ /* Layout */
+ --header-height: 3.25rem;
+ --sidebar-width: 16rem;
+ --gap: 0.75rem;
+ --radius-sm: 0.375rem;
+ --radius-md: 0.625rem;
+ --radius-lg: 0.875rem;
+
+ /* Typography */
+ --font-sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.35);
+ --shadow-md: 0 4px 12px rgba(0,0,0,0.45);
+ --shadow-lg: 0 10px 28px rgba(0,0,0,0.55);
+
+ /* Transitions */
+ --transition-fast: 120ms ease;
+ --transition-base: 200ms ease;
+}
+
+/* Light theme overrides */
+[data-theme="light"] {
+ --bg-body: #f4f5f8;
+ --bg-surface: #ffffff;
+ --bg-surface-hover: #f0f2f7;
+ --bg-elevated: #ffffff;
+ --bg-overlay: rgba(0,0,0,0.35);
+
+ --text-primary: #12131a;
+ --text-secondary: #4a4f5e;
+ --text-muted: #8b90a0;
+ --text-inverse: #ffffff;
+
+ --accent: #2b6cb0;
+ --accent-hover: #1a5490;
+ --accent-active: #17447a;
+ --accent-soft: rgba(43,108,176,0.14);
+
+ --border: #d6dae4;
+ --border-strong: #c3c9d6;
+
+ --danger: #c53030;
+ --danger-soft: rgba(197,48,48,0.14);
+ --success: #258855;
+ --success-soft: rgba(37,136,85,0.14);
+
+ --player-bg: #0b0d12;
+ --player-progress-bg: #d6dae4;
+ --player-progress-fill: #2b6cb0;
+ --player-thumb: #1a5490;
+
+ --selected-bg: rgba(43,108,176,0.18);
+ --selected-border: #2b6cb0;
+ --playing-bg: rgba(37,136,85,0.14);
+ --playing-border: #258855;
+
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.08);
+ --shadow-md: 0 4px 12px rgba(0,0,0,0.10);
+ --shadow-lg: 0 10px 28px rgba(0,0,0,0.12);
+}
diff --git a/web/favicon.svg b/web/favicon.svg
new file mode 100644
index 0000000..a77ff7a
--- /dev/null
+++ b/web/favicon.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#5e9eff"/><polygon points="40,30 70,50 40,70" fill="#fff"/></svg> \ No newline at end of file
diff --git a/web/index.html b/web/index.html
index 630ee64..f16c0de 100644
--- a/web/index.html
+++ b/web/index.html
@@ -2,9 +2,128 @@
<html lang="en">
<head>
<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KISS Media Player</title>
+<link rel="manifest" href="/manifest.json">
+<link rel="stylesheet" href="/css/theme.css">
+<link rel="stylesheet" href="/css/layout.css">
+<link rel="stylesheet" href="/css/components.css">
+<link rel="stylesheet" href="/css/player.css">
+<script type="module" src="/js/app.js"></script>
</head>
<body>
-<h1>Hello KISS</h1>
+<header class="site-header" role="banner">
+ <a href="/" class="logo">KISS Media</a>
+ <div class="search-bar" role="search">
+ <span aria-hidden="true">🔎</span>
+ <input id="search-input" type="search" placeholder="Search… (press /)" autocomplete="off" aria-label="Search media">
+ <button id="search-clear" class="icon-btn btn-sm" title="Clear">✕</button>
+ </div>
+ <div class="spacer"></div>
+ <button id="shuffle-toggle" class="icon-btn" title="Shuffle (r)">🔀</button>
+ <button id="admin-toggle" class="icon-btn hidden" title="Admin panel (a)">⚙</button>
+ <button id="theme-toggle" class="icon-btn" title="Toggle theme">☀</button>
+ <span id="user-name" class="text-secondary" style="font-size:0.85rem;"></span>
+ <button id="logout-btn" class="btn btn-ghost btn-sm">Log out</button>
+ <button id="menu-btn" class="icon-btn" title="Menu" aria-label="Open sets">☰</button>
+</header>
+
+<nav id="sidebar" class="sidebar" aria-label="Sets">
+ <h2>Sets</h2>
+ <div id="set-list"></div>
+</nav>
+
+<div class="page" role="main">
+ <div class="main-content">
+ <div class="toolbar" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
+ <select id="filter-type" aria-label="Filter by type">
+ <option value="">All</option>
+ <option value="video">Video</option>
+ <option value="audio">Audio</option>
+ </select>
+ <button id="filter-favorites" class="btn btn-ghost btn-sm">❤ Favorites</button>
+ <div class="spacer"></div>
+ <span id="result-count" class="text-muted" style="font-size:0.8rem;"></span>
+ </div>
+ <div id="media-grid" class="media-grid" tabindex="0" aria-label="Media grid"></div>
+ </div>
+</div>
+
+<aside id="player" class="player open" aria-label="Player">
+ <div class="stage">
+ <video id="media-video" playsinline controlslist="nodownload"></video>
+ <audio id="media-audio" style="display:none;"></audio>
+ <div id="big-play" class="big-play" role="button" aria-label="Play" tabindex="0">
+ <div class="play-icon">▶</div>
+ </div>
+ </div>
+ <div class="controls">
+ <button id="btn-play" class="icon-btn" aria-label="Play/Pause (Space/p)">▶</button>
+