@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.
- package/README.md +115 -0
- package/package.json +33 -0
- package/public/admin-utils.js +302 -0
- package/public/build.html +129 -0
- package/public/collections.html +100 -0
- package/public/dashboard.html +478 -0
- package/public/editor.html +1569 -0
- package/public/entries.html +367 -0
- package/public/favicon.svg +6 -0
- package/public/import.html +514 -0
- package/public/login.html +76 -0
- package/public/media.html +233 -0
- package/public/router.js +142 -0
- package/public/schema.html +366 -0
- package/public/search.js +209 -0
- package/public/settings.html +688 -0
- package/public/sidebar.js +90 -0
- package/public/style.css +1020 -0
- package/public/theme.js +63 -0
- package/public/users.html +192 -0
- package/src/index.js +4 -0
- package/src/middleware/auth.js +20 -0
- package/src/routes/account.js +41 -0
- package/src/routes/auth.js +55 -0
- package/src/routes/build.js +25 -0
- package/src/routes/collections.js +65 -0
- package/src/routes/entries.js +103 -0
- package/src/routes/github.js +133 -0
- package/src/routes/import.js +120 -0
- package/src/routes/info.js +19 -0
- package/src/routes/media.js +95 -0
- package/src/routes/meta.js +54 -0
- package/src/routes/search.js +62 -0
- package/src/routes/users.js +46 -0
- package/src/server.js +85 -0
- package/src/wp-importer.js +299 -0
|
@@ -0,0 +1,366 @@
|
|
|
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 — Schema</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
|
+
html,body,.app { height:100%; overflow:hidden; }
|
|
14
|
+
/* Override .main for schema — no padding, no scroll, split panels */
|
|
15
|
+
.main { padding:0 !important; overflow:hidden !important; display:grid !important; grid-template-columns:200px 1fr; }
|
|
16
|
+
|
|
17
|
+
.coll-panel { background:var(--bg1); border-right:1px solid var(--line); display:flex; flex-direction:column; overflow-y:auto; height:100%; }
|
|
18
|
+
.coll-panel-head { padding:14px 16px 10px; border-bottom:1px solid var(--line); display:flex; align-items:center; justify-content:space-between; flex-shrink:0; }
|
|
19
|
+
.coll-panel-title { font-size:9px; letter-spacing:0.28em; text-transform:uppercase; color:var(--muted); }
|
|
20
|
+
.btn-new-coll { font-size:20px; line-height:1; color:var(--gold); background:none; border:none; cursor:pointer; padding:0 2px; }
|
|
21
|
+
.coll-item { display:flex; align-items:center; justify-content:space-between; padding:9px 16px; font-size:11px; color:var(--mid); border-bottom:1px solid var(--line2); cursor:pointer; position:relative; transition:background .1s,color .1s; text-decoration:none; }
|
|
22
|
+
.coll-item:hover { background:var(--line2); color:var(--heading); }
|
|
23
|
+
.coll-item.active { background:var(--accent-bg); color:var(--heading); }
|
|
24
|
+
.coll-item.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--gold); }
|
|
25
|
+
.coll-count { font-size:9px; color:var(--muted); background:var(--bg3); border:1px solid var(--line); padding:1px 6px; border-radius:2px; }
|
|
26
|
+
.coll-empty { padding:24px 16px; font-size:10px; color:var(--muted); line-height:1.8; }
|
|
27
|
+
|
|
28
|
+
.editor-panel { padding:32px 40px; overflow-y:auto; height:100%; }
|
|
29
|
+
.editor-empty { display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:300px; gap:14px; color:var(--muted); text-align:center; }
|
|
30
|
+
.editor-empty-icon { font-size:28px; opacity:.2; }
|
|
31
|
+
.editor-head { margin-bottom:24px; padding-bottom:16px; border-bottom:1px solid var(--line); }
|
|
32
|
+
.editor-title { font-family:var(--serif); font-weight:200; font-size:24px; color:var(--heading); letter-spacing:0.04em; }
|
|
33
|
+
.editor-sub { font-size:10px; color:var(--muted); margin-top:4px; }
|
|
34
|
+
.meta-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:24px; }
|
|
35
|
+
.field-group { display:flex; flex-direction:column; gap:5px; }
|
|
36
|
+
.field-group label { font-size:10px; color:var(--muted); letter-spacing:0.06em; }
|
|
37
|
+
.field-id-hint { font-size:9px; color:var(--muted); margin-top:2px; }
|
|
38
|
+
.section-label { font-size:9px; letter-spacing:0.28em; text-transform:uppercase; color:var(--muted); margin-bottom:14px; display:flex; align-items:center; gap:8px; }
|
|
39
|
+
.section-label::before { content:"—"; color:var(--gold); }
|
|
40
|
+
.field-rows { background:var(--bg2); border:1px solid var(--line); margin-bottom:14px; border-radius:var(--radius); overflow:hidden; min-height:10px; }
|
|
41
|
+
.editor-actions { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:20px 0 0; border-top:1px solid var(--line); margin-top:20px; }
|
|
42
|
+
.banner { padding:8px 16px; font-size:10px; display:flex; align-items:center; gap:6px; border-radius:var(--radius); margin-bottom:14px; }
|
|
43
|
+
.banner-ok::before { content:"✓ "; }
|
|
44
|
+
.banner-err::before { content:"✕ "; }
|
|
45
|
+
.banner-ok { background:var(--jade-bg); color:var(--jade); border:1px solid rgba(45,139,106,.2); }
|
|
46
|
+
.banner-err { background:rgba(139,38,53,.07); color:var(--red); border:1px solid rgba(139,38,53,.15); }
|
|
47
|
+
.delete-warn { font-size:10px; color:var(--muted); padding:10px 12px; background:var(--gold-bg); border:1px solid rgba(200,168,107,.2); margin-bottom:16px; line-height:1.7; border-radius:var(--radius); }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<div class="app">
|
|
52
|
+
<header class="topbar">
|
|
53
|
+
<a class="logo" href="/dashboard.html"><div class="logo-mark">OR</div>Orbiter</a>
|
|
54
|
+
<div class="topbar-right">
|
|
55
|
+
<button class="search-trigger" id="search-btn" title="Search (⌘K)">
|
|
56
|
+
<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>
|
|
57
|
+
Search <kbd>⌘K</kbd>
|
|
58
|
+
</button>
|
|
59
|
+
<button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
|
|
60
|
+
<span class="user" id="topbar-user"></span>
|
|
61
|
+
<span class="logout" id="logout-btn">Sign out</span>
|
|
62
|
+
</div>
|
|
63
|
+
</header>
|
|
64
|
+
<nav class="sidebar">
|
|
65
|
+
<div class="nav-section">Content</div>
|
|
66
|
+
<a class="nav-item" href="/dashboard.html"><span class="nav-icon">◈</span>Dashboard</a>
|
|
67
|
+
<a class="nav-item" href="/collections.html"><span class="nav-icon">⊞</span>Collections</a>
|
|
68
|
+
<div class="nav-section">Assets</div>
|
|
69
|
+
<a class="nav-item" href="/media.html"><span class="nav-icon">⊟</span>Media</a>
|
|
70
|
+
<div class="nav-section">System</div>
|
|
71
|
+
<a class="nav-item active" href="/schema.html"><span class="nav-icon">◈</span>Schema</a>
|
|
72
|
+
<a class="nav-item" href="/build.html"><span class="nav-icon">▲</span>Build</a>
|
|
73
|
+
<a class="nav-item" href="/settings.html"><span class="nav-icon">◎</span>Settings</a>
|
|
74
|
+
<a class="nav-item admin-only" href="/users.html" style="display:none"><span class="nav-icon">◉</span>Users</a>
|
|
75
|
+
<div class="sidebar-footer">
|
|
76
|
+
<div class="pod-name" id="pod-name">content.pod</div>
|
|
77
|
+
<div class="pod-info" id="pod-info"></div>
|
|
78
|
+
<div class="pod-status"><span class="pod-dot"></span>pod synced</div>
|
|
79
|
+
</div>
|
|
80
|
+
</nav>
|
|
81
|
+
<div class="main">
|
|
82
|
+
<div class="coll-panel">
|
|
83
|
+
<div class="coll-panel-head">
|
|
84
|
+
<span class="coll-panel-title">Collections</span>
|
|
85
|
+
<button class="btn-new-coll" id="btn-new-coll" title="New collection">+</button>
|
|
86
|
+
</div>
|
|
87
|
+
<div id="coll-list"></div>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="editor-panel" id="editor-panel">
|
|
90
|
+
<div class="editor-empty">
|
|
91
|
+
<div class="editor-empty-icon">◈</div>
|
|
92
|
+
<div style="font-size:11px;line-height:1.8;">Select a collection or create a new one</div>
|
|
93
|
+
<button class="btn btn-primary" id="btn-new-empty">+ New collection</button>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<script type="module">
|
|
97
|
+
const me = await fetch('/api/auth/me',{credentials:'include'}).then(r=>r.json()).catch(()=>null);
|
|
98
|
+
if (!me?.user) { location.replace('/login.html'); }
|
|
99
|
+
document.getElementById('topbar-user').textContent = me.user.username;
|
|
100
|
+
if (me.user.role==='admin') document.querySelectorAll('.admin-only').forEach(el=>el.style.display='');
|
|
101
|
+
document.getElementById('logout-btn').addEventListener('click',async()=>{
|
|
102
|
+
await fetch('/api/auth/logout',{method:'POST',credentials:'include'});
|
|
103
|
+
location.replace('/login.html');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const FIELD_TYPES = [
|
|
107
|
+
{value:'string', label:'String — single line text'},
|
|
108
|
+
{value:'richtext', label:'Rich Text — block editor'},
|
|
109
|
+
{value:'date', label:'Date'},
|
|
110
|
+
{value:'datetime', label:'Date & time'},
|
|
111
|
+
{value:'array', label:'Array — tags, comma-separated'},
|
|
112
|
+
{value:'media', label:'Media — image / file'},
|
|
113
|
+
{value:'select', label:'Select — dropdown'},
|
|
114
|
+
{value:'url', label:'URL'},
|
|
115
|
+
{value:'number', label:'Number'},
|
|
116
|
+
{value:'weekdays', label:'Weekdays'},
|
|
117
|
+
{value:'relation', label:'Relation — link to collection'},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
let allColls = [];
|
|
121
|
+
let selectedId = null;
|
|
122
|
+
|
|
123
|
+
async function loadColls() {
|
|
124
|
+
allColls = await fetch('/api/collections',{credentials:'include'}).then(r=>r.json()).catch(()=>[]);
|
|
125
|
+
renderCollList();
|
|
126
|
+
if (selectedId) {
|
|
127
|
+
const col = allColls.find(c=>c.id===selectedId);
|
|
128
|
+
if (col) renderEditor(col); else { selectedId=null; renderEmpty(); }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderCollList() {
|
|
133
|
+
const list = document.getElementById('coll-list');
|
|
134
|
+
if (!allColls.length) {
|
|
135
|
+
list.innerHTML = '<div class="coll-empty">No collections yet.<br>Click + to create one.</div>';
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
list.innerHTML = allColls.map(c=>`
|
|
139
|
+
<div class="coll-item ${c.id===selectedId?'active':''}" data-id="${c.id}">
|
|
140
|
+
<span>${escHtml(c.label)}</span>
|
|
141
|
+
<span class="coll-count">${c.total ?? 0}</span>
|
|
142
|
+
</div>
|
|
143
|
+
`).join('');
|
|
144
|
+
list.querySelectorAll('.coll-item').forEach(el=>el.addEventListener('click',()=>{
|
|
145
|
+
selectedId = el.dataset.id;
|
|
146
|
+
renderCollList();
|
|
147
|
+
const col = allColls.find(c=>c.id===selectedId);
|
|
148
|
+
if (col) renderEditor(col);
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderEmpty() {
|
|
153
|
+
document.getElementById('editor-panel').innerHTML = `
|
|
154
|
+
<div class="editor-empty">
|
|
155
|
+
<div class="editor-empty-icon">◈</div>
|
|
156
|
+
<div style="font-size:11px;line-height:1.8;">Select a collection or create a new one</div>
|
|
157
|
+
<button class="btn btn-primary" id="btn-new-empty">+ New collection</button>
|
|
158
|
+
</div>
|
|
159
|
+
`;
|
|
160
|
+
document.getElementById('btn-new-empty').addEventListener('click',()=>renderNewForm());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const S = {
|
|
164
|
+
row: 'display:grid;grid-template-columns:1fr 260px;align-items:start;gap:20px;padding:14px 18px;border-bottom:1px solid var(--line2);background:var(--bg2);',
|
|
165
|
+
input: 'width:100%;background:var(--bg0);border:1px solid var(--line);padding:7px 9px;color:var(--heading);font-family:var(--mono);font-size:11px;outline:none;appearance:none;box-sizing:border-box;',
|
|
166
|
+
iKey: 'width:100%;background:var(--bg0);border:1px solid var(--line);padding:7px 9px;color:var(--heading);font-family:var(--mono);font-size:12px;outline:none;box-sizing:border-box;',
|
|
167
|
+
iLbl: 'width:100%;background:var(--bg0);border:1px solid var(--line);padding:6px 9px;color:var(--muted);font-family:var(--mono);font-size:10px;outline:none;box-sizing:border-box;',
|
|
168
|
+
reqW: 'display:flex;align-items:center;gap:6px;cursor:pointer;',
|
|
169
|
+
reqBox:'width:14px;height:14px;flex-shrink:0;border:1px solid var(--line);background:var(--bg0);display:flex;align-items:center;justify-content:center;font-size:8px;',
|
|
170
|
+
reqTxt:'font-size:10px;color:var(--muted);',
|
|
171
|
+
btnRm: 'background:none;border:none;color:var(--muted);font-size:13px;line-height:1;cursor:pointer;padding:2px 6px;',
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
function typeOptions(sel) {
|
|
175
|
+
return FIELD_TYPES.map(t=>`<option value="${t.value}"${t.value===sel?' selected':''}>${t.value} — ${t.label.split(' — ')[1]??t.label}</option>`).join('');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function collOptions(sel) {
|
|
179
|
+
return '<option value="">— select collection —</option>' +
|
|
180
|
+
allColls.filter(c=>c.id!==selectedId).map(c=>`<option value="${c.id}"${c.id===sel?' selected':''}>${escHtml(c.label)} (${c.id})</option>`).join('');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function addFieldRow(container, key='', label='', type='string', required=false, options='', collection='', multiple=true) {
|
|
184
|
+
const div = document.createElement('div');
|
|
185
|
+
div.style.cssText = S.row;
|
|
186
|
+
div.innerHTML = `
|
|
187
|
+
<div style="display:flex;flex-direction:column;gap:6px;">
|
|
188
|
+
<input class="f-key" style="${S.iKey}" placeholder="field_key" value="${escHtml(key)}" />
|
|
189
|
+
<input class="f-label" style="${S.iLbl}" placeholder="Field label" value="${escHtml(label)}" />
|
|
190
|
+
</div>
|
|
191
|
+
<div style="display:flex;flex-direction:column;gap:6px;">
|
|
192
|
+
<select class="f-type" style="${S.input}">${typeOptions(type)}</select>
|
|
193
|
+
<div class="fr-opts-select" style="display:${type==='select'?'block':'none'};">
|
|
194
|
+
<input class="f-options" style="${S.input}" placeholder="opt1, opt2, opt3" value="${escHtml(options)}" />
|
|
195
|
+
</div>
|
|
196
|
+
<div class="fr-opts-relation" style="display:${type==='relation'?'flex':'none'};flex-direction:column;gap:4px;">
|
|
197
|
+
<select class="f-rel-col" style="${S.input}">${collOptions(collection)}</select>
|
|
198
|
+
<label style="${S.reqW}">
|
|
199
|
+
<input type="checkbox" class="f-multiple"${multiple?' checked':''} style="display:none" />
|
|
200
|
+
<span class="mul-box" style="${S.reqBox}${multiple?'background:var(--gold);border-color:var(--gold);color:var(--bg0);':'color:transparent;'}">${multiple?'✓':''}</span>
|
|
201
|
+
<span style="${S.reqTxt}">Multiple</span>
|
|
202
|
+
</label>
|
|
203
|
+
</div>
|
|
204
|
+
<div style="display:flex;align-items:center;justify-content:space-between;">
|
|
205
|
+
<label style="${S.reqW}">
|
|
206
|
+
<input type="checkbox" class="f-required"${required?' checked':''} style="display:none" />
|
|
207
|
+
<span class="req-box" style="${S.reqBox}${required?'background:var(--gold);border-color:var(--gold);color:var(--bg0);':'color:transparent;'}">${required?'✓':''}</span>
|
|
208
|
+
<span style="${S.reqTxt}">Required</span>
|
|
209
|
+
</label>
|
|
210
|
+
<button type="button" style="${S.btnRm}" onclick="this.closest('[style]').remove()">✕</button>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
`;
|
|
214
|
+
const cb = div.querySelector('.f-required');
|
|
215
|
+
const box = div.querySelector('.req-box');
|
|
216
|
+
cb.addEventListener('change',function(){
|
|
217
|
+
box.style.background=this.checked?'var(--gold)':'var(--bg0)';
|
|
218
|
+
box.style.borderColor=this.checked?'var(--gold)':'var(--line)';
|
|
219
|
+
box.style.color=this.checked?'var(--bg0)':'transparent';
|
|
220
|
+
box.textContent=this.checked?'✓':'';
|
|
221
|
+
});
|
|
222
|
+
const mulCb=div.querySelector('.f-multiple'), mulBox=div.querySelector('.mul-box');
|
|
223
|
+
if (mulCb&&mulBox) {
|
|
224
|
+
mulCb.addEventListener('change',function(){
|
|
225
|
+
mulBox.style.background=this.checked?'var(--gold)':'var(--bg0)';
|
|
226
|
+
mulBox.style.borderColor=this.checked?'var(--gold)':'var(--line)';
|
|
227
|
+
mulBox.style.color=this.checked?'var(--bg0)':'transparent';
|
|
228
|
+
mulBox.textContent=this.checked?'✓':'';
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
div.querySelector('.f-type').addEventListener('change',function(){
|
|
232
|
+
div.querySelector('.fr-opts-select').style.display = this.value==='select' ?'block':'none';
|
|
233
|
+
div.querySelector('.fr-opts-relation').style.display = this.value==='relation' ?'flex':'none';
|
|
234
|
+
});
|
|
235
|
+
container.appendChild(div);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function serializeSchema(container) {
|
|
239
|
+
const schema = {};
|
|
240
|
+
container.querySelectorAll(':scope > div[style]').forEach(row=>{
|
|
241
|
+
const key = row.querySelector('.f-key')?.value.trim().toLowerCase().replace(/[^a-z0-9_]/g,'_');
|
|
242
|
+
if (!key) return;
|
|
243
|
+
const type = row.querySelector('.f-type').value;
|
|
244
|
+
const entry = { type, required:row.querySelector('.f-required').checked, label:row.querySelector('.f-label').value.trim()||key };
|
|
245
|
+
if (type==='select') entry.options=row.querySelector('.f-options').value.split(',').map(s=>s.trim()).filter(Boolean);
|
|
246
|
+
if (type==='relation') { entry.collection=row.querySelector('.f-rel-col').value; entry.multiple=row.querySelector('.f-multiple').checked; }
|
|
247
|
+
schema[key]=entry;
|
|
248
|
+
});
|
|
249
|
+
return schema;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function showBannerIn(id, cls, text) {
|
|
253
|
+
const el=document.getElementById(id);
|
|
254
|
+
if (!el) return;
|
|
255
|
+
el.className='banner '+cls; el.textContent=text; el.style.display='';
|
|
256
|
+
setTimeout(()=>el.style.display='none',3000);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderNewForm() {
|
|
260
|
+
selectedId=null; renderCollList();
|
|
261
|
+
const panel=document.getElementById('editor-panel');
|
|
262
|
+
panel.innerHTML=`
|
|
263
|
+
<div id="banner" class="banner" style="display:none"></div>
|
|
264
|
+
<div class="editor-head"><div class="editor-title">New collection</div></div>
|
|
265
|
+
<div class="meta-row">
|
|
266
|
+
<div class="field-group">
|
|
267
|
+
<label>Collection ID</label>
|
|
268
|
+
<input class="input" id="new-col-id" placeholder="e.g. posts, products" />
|
|
269
|
+
<div class="field-id-hint">Lowercase letters, numbers, underscores</div>
|
|
270
|
+
</div>
|
|
271
|
+
<div class="field-group">
|
|
272
|
+
<label>Label</label>
|
|
273
|
+
<input class="input" id="new-col-label" placeholder="e.g. Posts, Products" />
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
<div class="section-label">Fields</div>
|
|
277
|
+
<div id="new-field-rows" class="field-rows"></div>
|
|
278
|
+
<div class="editor-actions">
|
|
279
|
+
<div style="display:flex;gap:10px;">
|
|
280
|
+
<button class="btn btn-primary" id="btn-save-new">Save collection</button>
|
|
281
|
+
<button class="btn" id="btn-add-new">+ Add field</button>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
`;
|
|
285
|
+
document.getElementById('new-col-id').addEventListener('input',e=>{
|
|
286
|
+
e.target.value=e.target.value.toLowerCase().replace(/[^a-z0-9_]/g,'_').replace(/^_+/,'');
|
|
287
|
+
});
|
|
288
|
+
document.getElementById('btn-add-new').addEventListener('click',()=>addFieldRow(document.getElementById('new-field-rows')));
|
|
289
|
+
addFieldRow(document.getElementById('new-field-rows'),'title','Title','string',true);
|
|
290
|
+
document.getElementById('btn-save-new').addEventListener('click',async()=>{
|
|
291
|
+
const id=document.getElementById('new-col-id').value.trim();
|
|
292
|
+
const label=document.getElementById('new-col-label').value.trim();
|
|
293
|
+
if (!id||!label) { showBannerIn('banner','banner-err','ID and label are required'); return; }
|
|
294
|
+
const schema=serializeSchema(document.getElementById('new-field-rows'));
|
|
295
|
+
const res=await fetch('/api/collections',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,label,schema})});
|
|
296
|
+
const json=await res.json();
|
|
297
|
+
if (res.ok) { selectedId=id; await loadColls(); const col=allColls.find(c=>c.id===id); if(col)renderEditor(col); }
|
|
298
|
+
else showBannerIn('banner','banner-err',json.error??'Failed');
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function renderEditor(col) {
|
|
303
|
+
const schema = typeof col.schema==='string' ? JSON.parse(col.schema||'{}') : (col.schema??{});
|
|
304
|
+
const total = col.total ?? 0;
|
|
305
|
+
const panel = document.getElementById('editor-panel');
|
|
306
|
+
panel.innerHTML=`
|
|
307
|
+
<div id="banner" class="banner" style="display:none"></div>
|
|
308
|
+
<div class="editor-head">
|
|
309
|
+
<div class="editor-title">${escHtml(col.label)}</div>
|
|
310
|
+
<div class="editor-sub">id: ${col.id} · ${total} entr${total===1?'y':'ies'}</div>
|
|
311
|
+
</div>
|
|
312
|
+
<div class="meta-row">
|
|
313
|
+
<div class="field-group">
|
|
314
|
+
<label>Collection ID</label>
|
|
315
|
+
<input class="input" value="${col.id}" readonly />
|
|
316
|
+
<div class="field-id-hint">ID cannot be changed after creation</div>
|
|
317
|
+
</div>
|
|
318
|
+
<div class="field-group">
|
|
319
|
+
<label>Label</label>
|
|
320
|
+
<input class="input" id="edit-label" value="${escHtml(col.label)}" />
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
<div class="section-label">Fields</div>
|
|
324
|
+
${total>0?'<div class="delete-warn">Adding fields is non-destructive. Removing fields hides existing data but does not delete it.</div>':''}
|
|
325
|
+
<div id="edit-field-rows" class="field-rows"></div>
|
|
326
|
+
<div class="editor-actions">
|
|
327
|
+
<div style="display:flex;gap:10px;">
|
|
328
|
+
<button class="btn btn-primary" id="btn-save-edit">Save</button>
|
|
329
|
+
<button class="btn" id="btn-add-edit">+ Add field</button>
|
|
330
|
+
</div>
|
|
331
|
+
<button class="btn" id="btn-del-col" style="color:var(--red);border-color:var(--red);">Delete collection</button>
|
|
332
|
+
</div>
|
|
333
|
+
`;
|
|
334
|
+
const rowsEl=document.getElementById('edit-field-rows');
|
|
335
|
+
Object.entries(schema).forEach(([k,f])=>addFieldRow(rowsEl,k,f.label??k,f.type??'string',!!f.required,(f.options??[]).join(', '),f.collection??'',f.multiple!==false));
|
|
336
|
+
document.getElementById('btn-add-edit').addEventListener('click',()=>addFieldRow(rowsEl));
|
|
337
|
+
document.getElementById('btn-save-edit').addEventListener('click',async()=>{
|
|
338
|
+
const label=document.getElementById('edit-label').value.trim();
|
|
339
|
+
const schema=serializeSchema(rowsEl);
|
|
340
|
+
const res=await fetch(`/api/collections/${col.id}`,{method:'PUT',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({label,schema})});
|
|
341
|
+
if (res.ok) { await loadColls(); showBannerIn('banner','banner-ok','Saved'); }
|
|
342
|
+
else { const j=await res.json(); showBannerIn('banner','banner-err',j.error??'Failed'); }
|
|
343
|
+
});
|
|
344
|
+
document.getElementById('btn-del-col').addEventListener('click',async()=>{
|
|
345
|
+
const msg=total>0?`Delete "${col.label}" and all ${total} entries? This cannot be undone.`:`Delete collection "${col.label}"?`;
|
|
346
|
+
if (!confirm(msg)) return;
|
|
347
|
+
await fetch(`/api/collections/${col.id}`,{method:'DELETE',credentials:'include'});
|
|
348
|
+
selectedId=null; await loadColls(); renderEmpty();
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function escHtml(str) { return String(str??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
353
|
+
|
|
354
|
+
document.getElementById('btn-new-coll').addEventListener('click',()=>renderNewForm());
|
|
355
|
+
document.getElementById('btn-new-empty')?.addEventListener('click',()=>renderNewForm());
|
|
356
|
+
|
|
357
|
+
loadColls();
|
|
358
|
+
</script>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
<script src="/search.js"></script>
|
|
362
|
+
<script src="/sidebar.js"></script>
|
|
363
|
+
<script src="/router.js"></script>
|
|
364
|
+
</body>
|
|
365
|
+
</html>
|
|
366
|
+
|
package/public/search.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Global Cmd+K search palette — import this on any admin page
|
|
2
|
+
(function () {
|
|
3
|
+
const CSS = `
|
|
4
|
+
#search-overlay {
|
|
5
|
+
display: none;
|
|
6
|
+
position: fixed;
|
|
7
|
+
inset: 0;
|
|
8
|
+
background: rgba(0,0,0,0.65);
|
|
9
|
+
z-index: 9000;
|
|
10
|
+
align-items: flex-start;
|
|
11
|
+
justify-content: center;
|
|
12
|
+
padding-top: 15vh;
|
|
13
|
+
backdrop-filter: blur(2px);
|
|
14
|
+
}
|
|
15
|
+
#search-overlay.open { display: flex; }
|
|
16
|
+
#search-box {
|
|
17
|
+
background: var(--bg2);
|
|
18
|
+
border: 1px solid var(--line);
|
|
19
|
+
border-radius: var(--radius);
|
|
20
|
+
width: 560px;
|
|
21
|
+
max-width: 94vw;
|
|
22
|
+
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
|
|
23
|
+
overflow: hidden;
|
|
24
|
+
}
|
|
25
|
+
#search-input-wrap {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
gap: 10px;
|
|
29
|
+
padding: 0 16px;
|
|
30
|
+
border-bottom: 1px solid var(--line);
|
|
31
|
+
}
|
|
32
|
+
#search-input-wrap svg { color: var(--muted); flex-shrink: 0; }
|
|
33
|
+
#search-input {
|
|
34
|
+
flex: 1;
|
|
35
|
+
background: none;
|
|
36
|
+
border: none;
|
|
37
|
+
outline: none;
|
|
38
|
+
font-family: var(--mono);
|
|
39
|
+
font-size: 13px;
|
|
40
|
+
color: var(--text);
|
|
41
|
+
padding: 14px 0;
|
|
42
|
+
}
|
|
43
|
+
#search-results {
|
|
44
|
+
max-height: 360px;
|
|
45
|
+
overflow-y: auto;
|
|
46
|
+
}
|
|
47
|
+
#search-results:empty::after {
|
|
48
|
+
content: 'No results';
|
|
49
|
+
display: block;
|
|
50
|
+
text-align: center;
|
|
51
|
+
padding: 24px;
|
|
52
|
+
font-size: 12px;
|
|
53
|
+
color: var(--muted);
|
|
54
|
+
}
|
|
55
|
+
.sr-item {
|
|
56
|
+
display: flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
gap: 12px;
|
|
59
|
+
padding: 10px 16px;
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
border-bottom: 1px solid var(--line2);
|
|
62
|
+
text-decoration: none;
|
|
63
|
+
transition: background 0.1s;
|
|
64
|
+
}
|
|
65
|
+
.sr-item:last-child { border-bottom: none; }
|
|
66
|
+
.sr-item:hover, .sr-item.focused { background: var(--accent-bg); }
|
|
67
|
+
.sr-col {
|
|
68
|
+
font-size: 9px;
|
|
69
|
+
letter-spacing: 0.18em;
|
|
70
|
+
text-transform: uppercase;
|
|
71
|
+
color: var(--muted);
|
|
72
|
+
font-family: var(--mono);
|
|
73
|
+
min-width: 80px;
|
|
74
|
+
flex-shrink: 0;
|
|
75
|
+
}
|
|
76
|
+
.sr-title {
|
|
77
|
+
font-size: 13px;
|
|
78
|
+
color: var(--heading);
|
|
79
|
+
flex: 1;
|
|
80
|
+
white-space: nowrap;
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
text-overflow: ellipsis;
|
|
83
|
+
}
|
|
84
|
+
.sr-badge {
|
|
85
|
+
font-size: 9px;
|
|
86
|
+
font-family: var(--mono);
|
|
87
|
+
padding: 2px 6px;
|
|
88
|
+
border-radius: 3px;
|
|
89
|
+
flex-shrink: 0;
|
|
90
|
+
}
|
|
91
|
+
.sr-badge.published { background: var(--jade-bg); color: var(--jade); }
|
|
92
|
+
.sr-badge.draft { background: rgba(139,124,248,0.1); color: var(--accent); }
|
|
93
|
+
#search-footer {
|
|
94
|
+
padding: 8px 16px;
|
|
95
|
+
border-top: 1px solid var(--line);
|
|
96
|
+
display: flex;
|
|
97
|
+
gap: 16px;
|
|
98
|
+
font-size: 10px;
|
|
99
|
+
color: var(--muted);
|
|
100
|
+
font-family: var(--mono);
|
|
101
|
+
}
|
|
102
|
+
#search-footer kbd {
|
|
103
|
+
font-family: var(--mono);
|
|
104
|
+
background: var(--bg3, rgba(255,255,255,0.06));
|
|
105
|
+
border: 1px solid var(--line);
|
|
106
|
+
border-radius: 3px;
|
|
107
|
+
padding: 1px 5px;
|
|
108
|
+
font-size: 9px;
|
|
109
|
+
}
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
const style = document.createElement('style');
|
|
113
|
+
style.textContent = CSS;
|
|
114
|
+
document.head.appendChild(style);
|
|
115
|
+
|
|
116
|
+
const overlay = document.createElement('div');
|
|
117
|
+
overlay.id = 'search-overlay';
|
|
118
|
+
overlay.innerHTML = `
|
|
119
|
+
<div id="search-box" role="dialog" aria-label="Search">
|
|
120
|
+
<div id="search-input-wrap">
|
|
121
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
|
122
|
+
<input id="search-input" type="text" placeholder="Search entries…" autocomplete="off" spellcheck="false" />
|
|
123
|
+
<kbd>Esc</kbd>
|
|
124
|
+
</div>
|
|
125
|
+
<div id="search-results" role="listbox"></div>
|
|
126
|
+
<div id="search-footer">
|
|
127
|
+
<span><kbd>↑↓</kbd> navigate</span>
|
|
128
|
+
<span><kbd>↵</kbd> open</span>
|
|
129
|
+
<span><kbd>Esc</kbd> close</span>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
`;
|
|
133
|
+
document.body.appendChild(overlay);
|
|
134
|
+
|
|
135
|
+
const input = document.getElementById('search-input');
|
|
136
|
+
const results = document.getElementById('search-results');
|
|
137
|
+
let items = [];
|
|
138
|
+
let focused = -1;
|
|
139
|
+
let debounce = null;
|
|
140
|
+
|
|
141
|
+
function open() {
|
|
142
|
+
overlay.classList.add('open');
|
|
143
|
+
input.value = '';
|
|
144
|
+
results.innerHTML = '';
|
|
145
|
+
items = [];
|
|
146
|
+
focused = -1;
|
|
147
|
+
input.focus();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function close() {
|
|
151
|
+
overlay.classList.remove('open');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function setFocus(idx) {
|
|
155
|
+
items.forEach((el, i) => el.classList.toggle('focused', i === idx));
|
|
156
|
+
focused = idx;
|
|
157
|
+
if (items[idx]) items[idx].scrollIntoView({ block: 'nearest' });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function search(q) {
|
|
161
|
+
if (!q.trim()) { results.innerHTML = ''; items = []; focused = -1; return; }
|
|
162
|
+
const data = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { credentials: 'include' })
|
|
163
|
+
.then(r => r.json()).catch(() => []);
|
|
164
|
+
results.innerHTML = '';
|
|
165
|
+
items = [];
|
|
166
|
+
focused = -1;
|
|
167
|
+
if (!data.length) return;
|
|
168
|
+
data.forEach(hit => {
|
|
169
|
+
const a = document.createElement('a');
|
|
170
|
+
a.className = 'sr-item';
|
|
171
|
+
a.href = `/editor.html?collection=${hit.collection}&slug=${hit.slug}`;
|
|
172
|
+
a.innerHTML = `
|
|
173
|
+
<span class="sr-col">${hit.collection}</span>
|
|
174
|
+
<span class="sr-title">${hit.title || hit.slug}</span>
|
|
175
|
+
<span class="sr-badge ${hit.status}">${hit.status}</span>
|
|
176
|
+
`;
|
|
177
|
+
a.addEventListener('mouseenter', () => setFocus(items.indexOf(a)));
|
|
178
|
+
results.appendChild(a);
|
|
179
|
+
items.push(a);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
input.addEventListener('input', () => {
|
|
184
|
+
clearTimeout(debounce);
|
|
185
|
+
debounce = setTimeout(() => search(input.value), 180);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
document.addEventListener('keydown', e => {
|
|
189
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
overlay.classList.contains('open') ? close() : open();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!overlay.classList.contains('open')) return;
|
|
195
|
+
if (e.key === 'Escape') { close(); return; }
|
|
196
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setFocus(Math.min(focused + 1, items.length - 1)); return; }
|
|
197
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); setFocus(Math.max(focused - 1, 0)); return; }
|
|
198
|
+
if (e.key === 'Enter' && items[focused]) { items[focused].click(); return; }
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
overlay.addEventListener('mousedown', e => {
|
|
202
|
+
if (e.target === overlay) close();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Wire click on any .search-trigger button (present after DOM is ready)
|
|
206
|
+
document.addEventListener('click', e => {
|
|
207
|
+
if (e.target.closest('#search-btn')) open();
|
|
208
|
+
});
|
|
209
|
+
})();
|