@a83/orbiter-admin 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Orbiter Admin — Media</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Space+Grotesk:wght@300;400;500;600&family=Noto+Serif+JP:wght@200;300&family=DM+Mono:wght@300;400&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="/style.css" />
11
+ <script src="/theme.js"></script>
12
+ <style>
13
+ .upload-area { background:var(--bg2); border:2px dashed var(--line); padding:32px; text-align:center; cursor:pointer; transition:border-color .15s,background .15s; display:block; border-radius:var(--radius); margin-bottom:12px; }
14
+ .upload-area:hover,.upload-area.drag-over { border-color:var(--gold); background:var(--gold-bg); }
15
+ .upload-icon { font-size:22px; color:var(--muted); margin-bottom:8px; }
16
+ .upload-label { font-size:13px; color:var(--mid); }
17
+ .upload-sub { font-size:11px; color:var(--muted); margin-top:4px; }
18
+ .upload-form-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
19
+ .filter-bar { display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin-bottom:20px; margin-top:24px; }
20
+ .filter-btn { background:transparent; border:1px solid var(--line); color:var(--muted); font-family:var(--mono); font-size:10px; padding:4px 12px; cursor:pointer; letter-spacing:0.06em; transition:all .12s; border-radius:2px; }
21
+ .filter-btn:hover { border-color:var(--mid); color:var(--text); }
22
+ .filter-btn.active { border-color:var(--gold); color:var(--gold); background:var(--gold-bg); }
23
+ .media-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:12px; }
24
+ .media-card { background:var(--bg2); border:1px solid var(--line); overflow:hidden; position:relative; border-radius:var(--radius); transition:border-color .15s; }
25
+ .media-card:hover { border-color:var(--gold); }
26
+ .media-card:hover .media-overlay { opacity:1; }
27
+ .media-thumb { width:100%; height:140px; object-fit:cover; display:block; }
28
+ .media-thumb-ph { width:100%; height:140px; display:flex; flex-direction:column; align-items:center; justify-content:center; background:var(--bg3); gap:8px; }
29
+ .media-thumb-icon { font-size:28px; color:var(--muted); }
30
+ .media-thumb-type { font-family:var(--mono); font-size:9px; color:var(--muted); letter-spacing:.12em; text-transform:uppercase; }
31
+ .media-info { padding:10px 12px; border-top:1px solid var(--line); }
32
+ .media-name { font-size:10px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:2px; }
33
+ .media-meta { font-size:9px; color:var(--muted); display:flex; justify-content:space-between; }
34
+ .media-overlay { position:absolute; inset:0; background:rgba(0,0,0,.5); opacity:0; transition:opacity .15s; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; }
35
+ .btn-copy-url { background:var(--bg2); border:1px solid var(--line); color:var(--text); font-family:var(--mono); font-size:9px; padding:5px 14px; cursor:pointer; letter-spacing:.06em; border-radius:2px; }
36
+ .btn-copy-url:hover { background:var(--bg3); }
37
+ .btn-copy-url.copied { color:var(--jade); border-color:var(--jade); }
38
+ .btn-del-media { background:transparent; border:1px solid rgba(255,255,255,.3); color:rgba(255,255,255,.8); font-family:var(--mono); font-size:9px; padding:4px 12px; cursor:pointer; border-radius:2px; }
39
+ .btn-del-media:hover { background:var(--red); border-color:var(--red); color:#fff; }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div class="app">
44
+ <header class="topbar">
45
+ <a class="logo" href="/dashboard.html"><div class="logo-mark">OR</div>Orbiter</a>
46
+ <div class="topbar-right">
47
+ <button class="search-trigger" id="search-btn" title="Search (⌘K)">
48
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
49
+ Search <kbd>⌘K</kbd>
50
+ </button>
51
+ <button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
52
+ <span class="user" id="topbar-user"></span>
53
+ <span class="logout" id="logout-btn">Sign out</span>
54
+ </div>
55
+ </header>
56
+ <nav class="sidebar">
57
+ <div class="nav-section">Content</div>
58
+ <a class="nav-item" href="/dashboard.html"><span class="nav-icon">◈</span>Dashboard</a>
59
+ <a class="nav-item" href="/collections.html"><span class="nav-icon">⊞</span>Collections</a>
60
+ <div class="nav-section">Assets</div>
61
+ <a class="nav-item active" href="/media.html"><span class="nav-icon">⊟</span>Media</a>
62
+ <div class="nav-section">System</div>
63
+ <a class="nav-item" href="/schema.html"><span class="nav-icon">◈</span>Schema</a>
64
+ <a class="nav-item" href="/build.html"><span class="nav-icon">▲</span>Build</a>
65
+ <a class="nav-item" href="/settings.html"><span class="nav-icon">◎</span>Settings</a>
66
+ <a class="nav-item admin-only" href="/users.html" style="display:none"><span class="nav-icon">◉</span>Users</a>
67
+ <div class="sidebar-footer">
68
+ <div class="pod-name" id="pod-name">content.pod</div>
69
+ <div class="pod-info" id="pod-info"></div>
70
+ <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
71
+ </div>
72
+ </nav>
73
+ <main class="main">
74
+ <div class="page-header">
75
+ <h1 class="page-title">Media</h1>
76
+ <p class="page-sub" id="media-count"></p>
77
+ </div>
78
+
79
+ <!-- Upload -->
80
+ <label class="upload-area" id="drop-zone">
81
+ <input type="file" id="file-input" accept="image/*,video/*,application/pdf" style="display:none" multiple />
82
+ <div class="upload-icon">↑</div>
83
+ <div class="upload-label" id="upload-label">Drop files here or click to upload</div>
84
+ <div class="upload-sub">Images, video, PDF</div>
85
+ </label>
86
+ <div class="upload-form-row" style="margin-bottom:24px">
87
+ <input class="input" id="alt-input" placeholder="Alt text (optional)" style="width:200px" />
88
+ <input class="input" id="folder-input" list="folder-list" placeholder="Folder (optional)" style="width:160px" />
89
+ <datalist id="folder-list"></datalist>
90
+ <button class="btn btn-primary" id="upload-btn">Upload</button>
91
+ <span id="upload-status" style="font-size:12px;color:var(--muted)"></span>
92
+ </div>
93
+
94
+ <div class="filter-bar" id="filter-bar" style="display:none"></div>
95
+ <div id="media-grid-wrap"><div class="empty"><div class="spinner"></div></div></div>
96
+
97
+ <script type="module">
98
+ const me = await fetch('/api/auth/me', { credentials:'include' }).then(r=>r.json()).catch(()=>null);
99
+ if (!me?.user) { location.replace('/login.html'); }
100
+ document.getElementById('topbar-user').textContent = me.user.username;
101
+ if (me.user.role === 'admin') document.querySelectorAll('.admin-only').forEach(el=>el.style.display='');
102
+ document.getElementById('logout-btn').addEventListener('click', async ()=>{
103
+ await fetch('/api/auth/logout',{method:'POST',credentials:'include'});
104
+ location.replace('/login.html');
105
+ });
106
+
107
+ const fmtSize = b => b < 1048576 ? (b/1024).toFixed(1)+' KB' : (b/1048576).toFixed(1)+' MB';
108
+ let allMedia = [];
109
+
110
+ async function loadMedia() {
111
+ allMedia = await fetch('/api/media',{credentials:'include'}).then(r=>r.json());
112
+ document.getElementById('media-count').textContent = `${allMedia.length} asset${allMedia.length!==1?'s':''}`;
113
+ renderFilterBar();
114
+ renderGrid(allMedia);
115
+
116
+ // Populate folder datalist
117
+ const folders = [...new Set(allMedia.map(m=>m.folder).filter(Boolean))].sort();
118
+ const dl = document.getElementById('folder-list');
119
+ dl.innerHTML = folders.map(f=>`<option value="${f}">`).join('');
120
+ }
121
+
122
+ function renderFilterBar() {
123
+ const bar = document.getElementById('filter-bar');
124
+ if (allMedia.length === 0) { bar.style.display='none'; return; }
125
+ bar.style.display = 'flex';
126
+ const folders = [...new Set(allMedia.map(m=>m.folder).filter(Boolean))].sort();
127
+ const hasImage = allMedia.some(m=>m.mime_type.startsWith('image/'));
128
+ const hasVideo = allMedia.some(m=>m.mime_type.startsWith('video/'));
129
+ const hasPdf = allMedia.some(m=>m.mime_type==='application/pdf');
130
+ bar.innerHTML = `
131
+ <button class="filter-btn active" data-f="all">All (${allMedia.length})</button>
132
+ ${hasImage ? `<button class="filter-btn" data-f="image">Images</button>` : ''}
133
+ ${hasVideo ? `<button class="filter-btn" data-f="video">Video</button>` : ''}
134
+ ${hasPdf ? `<button class="filter-btn" data-f="pdf">PDF</button>` : ''}
135
+ ${folders.map(f=>`<button class="filter-btn" data-f="folder:${f}">${f}</button>`).join('')}
136
+ `;
137
+ bar.querySelectorAll('.filter-btn').forEach(btn=>btn.addEventListener('click', ()=>{
138
+ bar.querySelectorAll('.filter-btn').forEach(b=>b.classList.remove('active'));
139
+ btn.classList.add('active');
140
+ const f = btn.dataset.f;
141
+ let filtered = allMedia;
142
+ if (f==='image') filtered = allMedia.filter(m=>m.mime_type.startsWith('image/'));
143
+ else if (f==='video') filtered = allMedia.filter(m=>m.mime_type.startsWith('video/'));
144
+ else if (f==='pdf') filtered = allMedia.filter(m=>m.mime_type==='application/pdf');
145
+ else if (f.startsWith('folder:')) filtered = allMedia.filter(m=>m.folder===f.slice(7));
146
+ renderGrid(filtered);
147
+ }));
148
+ }
149
+
150
+ function renderGrid(items) {
151
+ const wrap = document.getElementById('media-grid-wrap');
152
+ if (items.length===0) { wrap.innerHTML='<div class="empty"><div class="empty-icon">⊟</div>No media yet</div>'; return; }
153
+ wrap.innerHTML = `<div class="media-grid">${items.map(m=>{
154
+ const isImg = m.mime_type.startsWith('image/');
155
+ const isVid = m.mime_type.startsWith('video/');
156
+ const thumb = isImg
157
+ ? `<img class="media-thumb" src="/api/media/${m.id}/raw" alt="${m.alt??''}" loading="lazy" />`
158
+ : isVid
159
+ ? `<video class="media-thumb" src="/api/media/${m.id}/raw" muted preload="metadata"></video>`
160
+ : `<div class="media-thumb-ph"><div class="media-thumb-icon">${m.mime_type==='application/pdf'?'⊠':'◫'}</div><div class="media-thumb-type">${m.mime_type.split('/')[1]?.toUpperCase()}</div></div>`;
161
+ return `<div class="media-card" data-id="${m.id}">
162
+ ${thumb}
163
+ <div class="media-info">
164
+ <div class="media-name" title="${m.filename}">${m.filename}</div>
165
+ <div class="media-meta"><span>${fmtSize(m.size)}</span><span>${m.folder||m.created_at?.split(' ')[0]||''}</span></div>
166
+ </div>
167
+ <div class="media-overlay">
168
+ <button class="btn-copy-url" data-id="${m.id}">Copy URL</button>
169
+ <button class="btn-del-media" data-id="${m.id}">Delete</button>
170
+ </div>
171
+ </div>`;
172
+ }).join('')}</div>`;
173
+
174
+ wrap.querySelectorAll('.btn-copy-url').forEach(btn=>btn.addEventListener('click', ()=>{
175
+ const url = `${location.origin}/api/media/${btn.dataset.id}/raw`;
176
+ navigator.clipboard.writeText(url).then(()=>{
177
+ const orig=btn.textContent; btn.textContent='Copied!'; btn.classList.add('copied');
178
+ setTimeout(()=>{btn.textContent=orig;btn.classList.remove('copied');},1800);
179
+ });
180
+ }));
181
+
182
+ wrap.querySelectorAll('.btn-del-media').forEach(btn=>btn.addEventListener('click', async ()=>{
183
+ if (!confirm('Delete this file?')) return;
184
+ await fetch(`/api/media/${btn.dataset.id}`,{method:'DELETE',credentials:'include'});
185
+ loadMedia();
186
+ }));
187
+ }
188
+
189
+ // Upload
190
+ const fileInput = document.getElementById('file-input');
191
+ const dropZone = document.getElementById('drop-zone');
192
+ const uploadLabel= document.getElementById('upload-label');
193
+
194
+ fileInput.addEventListener('change', ()=>{
195
+ if (fileInput.files.length) uploadLabel.textContent = fileInput.files.length > 1 ? `${fileInput.files.length} files selected` : fileInput.files[0].name;
196
+ });
197
+ dropZone.addEventListener('dragover', e=>{e.preventDefault();dropZone.classList.add('drag-over');});
198
+ dropZone.addEventListener('dragleave', ()=>dropZone.classList.remove('drag-over'));
199
+ dropZone.addEventListener('drop', e=>{
200
+ e.preventDefault(); dropZone.classList.remove('drag-over');
201
+ if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; uploadLabel.textContent = e.dataTransfer.files[0].name; }
202
+ });
203
+
204
+ document.getElementById('upload-btn').addEventListener('click', async ()=>{
205
+ const files = fileInput.files;
206
+ const status = document.getElementById('upload-status');
207
+ if (!files.length) { status.textContent='No file selected'; return; }
208
+ const alt = document.getElementById('alt-input').value;
209
+ const folder = document.getElementById('folder-input').value;
210
+ status.textContent = `Uploading ${files.length} file(s)…`;
211
+ let ok=0;
212
+ for (const file of files) {
213
+ const fd = new FormData();
214
+ fd.append('file', file); fd.append('alt', alt); fd.append('folder', folder);
215
+ const res = await fetch('/api/media',{method:'POST',credentials:'include',body:fd});
216
+ if (res.ok) ok++;
217
+ }
218
+ status.style.color = ok===files.length ? 'var(--jade)' : 'var(--red)';
219
+ status.textContent = `✓ ${ok} of ${files.length} uploaded`;
220
+ uploadLabel.textContent = 'Drop files here or click to upload';
221
+ setTimeout(()=>{status.textContent='';status.style.color='';},3000);
222
+ loadMedia();
223
+ });
224
+
225
+ loadMedia();
226
+ </script>
227
+ </main>
228
+ </div>
229
+ <script src="/search.js"></script>
230
+ <script src="/sidebar.js"></script>
231
+ <script src="/router.js"></script>
232
+ </body>
233
+ </html>
@@ -0,0 +1,142 @@
1
+ /**
2
+ * router.js — client-side SPA router.
3
+ * Intercepts <a> clicks to .html pages, swaps only .main content,
4
+ * keeps sidebar/topbar persistent, adds fade transitions.
5
+ */
6
+ (function () {
7
+ if (window.__orbRouter) return;
8
+ window.__orbRouter = true;
9
+
10
+ var main = document.querySelector('.main');
11
+ if (!main) return; // login page or no-layout page — skip
12
+
13
+ // Tag the initial page-specific <style> so we can swap it later
14
+ var headStyle = document.querySelector('head > style:not([id])');
15
+ if (headStyle) headStyle.id = '__page-style';
16
+
17
+ // Inject transition rule once
18
+ var transCSS = document.createElement('style');
19
+ transCSS.textContent = '.main { transition: opacity 0.12s ease; } .main.fading { opacity: 0; }';
20
+ document.head.appendChild(transCSS);
21
+
22
+ // --- helpers ---
23
+
24
+ function updateActiveNav(href) {
25
+ var target;
26
+ try { target = new URL(href, location.origin); } catch (e) { return; }
27
+ document.querySelectorAll('.nav-item').forEach(function (a) {
28
+ var raw = a.getAttribute('href');
29
+ if (!raw) return;
30
+ var itemUrl;
31
+ try { itemUrl = new URL(raw, location.origin); } catch (e) { return; }
32
+ var match = itemUrl.pathname === target.pathname;
33
+ if (match && itemUrl.search) {
34
+ itemUrl.searchParams.forEach(function (v, k) {
35
+ if (target.searchParams.get(k) !== v) match = false;
36
+ });
37
+ }
38
+ a.classList.toggle('active', match);
39
+ });
40
+ }
41
+
42
+ function swapPageStyle(doc) {
43
+ var old = document.getElementById('__page-style');
44
+ if (old) old.remove();
45
+ var fresh = doc.querySelector('head > style');
46
+ if (fresh) {
47
+ fresh.id = '__page-style';
48
+ document.head.appendChild(fresh);
49
+ }
50
+ }
51
+
52
+ function execModuleScripts(container) {
53
+ container.querySelectorAll('script[type="module"]').forEach(function (old) {
54
+ var code = old.textContent;
55
+ old.remove();
56
+ // Blob URL import() is the only reliable way to re-execute module scripts
57
+ // (replaceChild/innerHTML does not re-trigger browser module execution)
58
+ var blob = new Blob([code], { type: 'text/javascript' });
59
+ var blobUrl = URL.createObjectURL(blob);
60
+ import(blobUrl).catch(function (e) {
61
+ console.error('[router] page script error:', e);
62
+ }).finally(function () {
63
+ URL.revokeObjectURL(blobUrl);
64
+ });
65
+ });
66
+ }
67
+
68
+ function navigate(href, replace) {
69
+ // Kick off fade-out and fetch in parallel
70
+ main.classList.add('fading');
71
+
72
+ var fetchP = fetch(href, { credentials: 'include' }).then(function (r) { return r.text(); });
73
+ var timerP = new Promise(function (res) { setTimeout(res, 120); });
74
+
75
+ Promise.all([fetchP, timerP]).then(function (results) {
76
+ var html = results[0];
77
+ var doc = new DOMParser().parseFromString(html, 'text/html');
78
+
79
+ // Swap page-specific <style>
80
+ swapPageStyle(doc);
81
+
82
+ // Swap <title>
83
+ var newTitle = doc.querySelector('title');
84
+ if (newTitle) document.title = newTitle.textContent;
85
+
86
+ // Swap .main content
87
+ var newMain = doc.querySelector('.main');
88
+ main.innerHTML = newMain ? newMain.innerHTML : '';
89
+
90
+ // Update URL
91
+ if (replace) {
92
+ history.replaceState(null, '', href);
93
+ } else {
94
+ history.pushState(null, '', href);
95
+ }
96
+
97
+ // Update sidebar active state
98
+ updateActiveNav(href);
99
+
100
+ // Re-execute module scripts (now live inside .main on every page)
101
+ execModuleScripts(main);
102
+
103
+ // Fade back in on next frame
104
+ requestAnimationFrame(function () {
105
+ main.classList.remove('fading');
106
+ });
107
+
108
+ // Scroll to top
109
+ main.scrollTop = 0;
110
+ }).catch(function () {
111
+ location.href = href; // fallback: full navigation
112
+ });
113
+ }
114
+
115
+ // --- intercept clicks ---
116
+
117
+ document.addEventListener('click', function (e) {
118
+ var a = e.target.closest('a[href]');
119
+ if (!a || a.target === '_blank') return;
120
+ var raw = a.getAttribute('href');
121
+ if (!raw) return;
122
+ var url;
123
+ try { url = new URL(raw, location.origin); } catch (e) { return; }
124
+ if (url.origin !== location.origin) return;
125
+ if (!url.pathname.endsWith('.html')) return;
126
+ if (url.pathname === '/login.html') return;
127
+ if (url.pathname === '/editor.html') return; // full-screen layout, no .main to swap
128
+ e.preventDefault();
129
+ if (url.href === location.href) return;
130
+ navigate(url.href, false);
131
+ }, true); // capture phase so editor-internal links don't block
132
+
133
+ // --- browser back/forward ---
134
+
135
+ window.addEventListener('popstate', function () {
136
+ navigate(location.href, true);
137
+ });
138
+
139
+ // Set initial active state (sidebar.js sets it during build, but
140
+ // router also manages it on subsequent navigations)
141
+ updateActiveNav(location.href);
142
+ })();