@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,514 @@
|
|
|
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" />
|
|
7
|
+
<title>Import — Orbiter</title>
|
|
8
|
+
<script src="/theme.js"></script>
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<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">
|
|
11
|
+
<link rel="stylesheet" href="/style.css" />
|
|
12
|
+
<style>
|
|
13
|
+
.import-wrap { padding: 40px 48px; max-width: 720px; }
|
|
14
|
+
|
|
15
|
+
.import-head { margin-bottom: 32px; padding-bottom: 20px; border-bottom: 1px solid var(--line); }
|
|
16
|
+
.import-title { font-family: var(--display); font-weight: 500; font-size: 18px; color: var(--heading); letter-spacing: 0.02em; margin-bottom: 6px; }
|
|
17
|
+
.import-sub { font-size: 10px; color: var(--muted); }
|
|
18
|
+
|
|
19
|
+
.import-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--line); margin-bottom: 24px; }
|
|
20
|
+
.import-tab { font-size: 10px; padding: 8px 18px; border: none; border-bottom: 2px solid transparent; background: none; color: var(--muted); font-family: var(--mono); cursor: pointer; letter-spacing: 0.06em; transition: color 0.15s, border-color 0.15s; }
|
|
21
|
+
.import-tab.active { color: var(--heading); border-bottom-color: var(--gold); }
|
|
22
|
+
|
|
23
|
+
.drop-zone { border: 1px dashed var(--line); border-radius: var(--radius); padding: 40px; text-align: center; cursor: pointer; transition: border-color 0.15s, background 0.15s; position: relative; }
|
|
24
|
+
.drop-zone:hover, .drop-zone.drag { border-color: var(--gold); background: var(--gold-bg); }
|
|
25
|
+
.drop-zone input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
|
|
26
|
+
.drop-icon { font-size: 28px; color: var(--muted); opacity: 0.3; margin-bottom: 12px; }
|
|
27
|
+
.drop-label { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
|
|
28
|
+
.drop-sub { font-size: 9px; color: var(--muted); opacity: 0.6; }
|
|
29
|
+
.drop-chosen { font-size: 11px; color: var(--gold); margin-top: 10px; display: none; }
|
|
30
|
+
|
|
31
|
+
.section { margin-bottom: 28px; }
|
|
32
|
+
.section-head { font-size: 9px; letter-spacing: 0.26em; text-transform: uppercase; color: var(--muted); margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
|
33
|
+
.section-head::before { content: "—"; color: var(--gold); }
|
|
34
|
+
|
|
35
|
+
.type-row { display: grid; grid-template-columns: 24px 1fr auto auto; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--line2); background: var(--bg2); }
|
|
36
|
+
.type-row:first-of-type { border-top: 1px solid var(--line); border-radius: var(--radius) var(--radius) 0 0; }
|
|
37
|
+
.type-row:last-of-type { border-radius: 0 0 var(--radius) var(--radius); }
|
|
38
|
+
.type-name { font-size: 12px; color: var(--text); }
|
|
39
|
+
.type-col-id { font-size: 9px; color: var(--muted); font-family: var(--mono); }
|
|
40
|
+
.type-count { font-size: 10px; color: var(--muted); text-align: right; white-space: nowrap; }
|
|
41
|
+
.type-badge { font-size: 9px; color: var(--jade); background: var(--jade-bg); padding: 1px 6px; border-radius: 3px; white-space: nowrap; }
|
|
42
|
+
|
|
43
|
+
.opt-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--line2); }
|
|
44
|
+
.opt-row:last-child { border-bottom: none; }
|
|
45
|
+
.opt-label { font-size: 11px; color: var(--text); flex: 1; }
|
|
46
|
+
.opt-desc { font-size: 9px; color: var(--muted); display: block; margin-top: 2px; }
|
|
47
|
+
|
|
48
|
+
.ck-wrap { display: flex; align-items: center; gap: 7px; cursor: pointer; }
|
|
49
|
+
.ck-box { width: 14px; height: 14px; border: 1px solid var(--line); background: var(--bg0); display: flex; align-items: center; justify-content: center; font-size: 8px; color: transparent; flex-shrink: 0; transition: all 0.12s; border-radius: 2px; }
|
|
50
|
+
.ck-wrap.checked .ck-box { background: var(--gold); border-color: var(--gold); color: var(--bg0); }
|
|
51
|
+
.ck-txt { font-size: 11px; color: var(--text); }
|
|
52
|
+
|
|
53
|
+
.radio-group { display: flex; gap: 0; }
|
|
54
|
+
.radio-pill { position: relative; }
|
|
55
|
+
.radio-pill input { display: none; }
|
|
56
|
+
.radio-pill label { display: block; padding: 5px 14px; font-size: 10px; color: var(--muted); border: 1px solid var(--line); border-right-width: 0; cursor: pointer; transition: all 0.12s; letter-spacing: 0.04em; }
|
|
57
|
+
.radio-pill:last-child label { border-right-width: 1px; border-radius: 0 var(--radius) var(--radius) 0; }
|
|
58
|
+
.radio-pill:first-child label { border-radius: var(--radius) 0 0 var(--radius); }
|
|
59
|
+
.radio-pill input:checked + label { color: var(--gold); border-color: var(--gold); background: var(--gold-bg); z-index: 1; position: relative; }
|
|
60
|
+
|
|
61
|
+
.site-banner { background: var(--bg2); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 24px; display: flex; align-items: center; gap: 12px; }
|
|
62
|
+
.site-banner-icon { font-size: 18px; opacity: 0.2; }
|
|
63
|
+
.site-banner-name { font-size: 13px; color: var(--heading); }
|
|
64
|
+
.site-banner-url { font-size: 10px; color: var(--muted); margin-top: 1px; }
|
|
65
|
+
|
|
66
|
+
.result-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line2); font-size: 11px; }
|
|
67
|
+
.result-row:last-child { border-bottom: none; }
|
|
68
|
+
.result-num { font-family: var(--serif); font-size: 20px; font-weight: 200; min-width: 36px; text-align: right; }
|
|
69
|
+
.result-ok { color: var(--jade); }
|
|
70
|
+
.result-warn { color: var(--gold); }
|
|
71
|
+
.result-err { font-size: 10px; padding: 4px 8px; background: var(--crimson-bg, rgba(139,38,53,0.07)); border: 1px solid rgba(139,38,53,0.15); margin-top: 4px; color: var(--crimson, #8b2635); border-radius: 3px; }
|
|
72
|
+
|
|
73
|
+
.import-actions { display: flex; align-items: center; gap: 10px; padding-top: 24px; border-top: 1px solid var(--line); margin-top: 24px; }
|
|
74
|
+
|
|
75
|
+
.err-banner { background: var(--crimson-bg, rgba(139,38,53,0.07)); border: 1px solid rgba(139,38,53,0.15); border-radius: var(--radius); padding: 10px 14px; font-size: 10px; color: var(--crimson, #8b2635); margin-bottom: 20px; display: flex; align-items: center; gap: 8px; }
|
|
76
|
+
.err-banner::before { content: "✕"; }
|
|
77
|
+
|
|
78
|
+
.spinner-wrap { text-align: center; padding: 40px; color: var(--muted); font-size: 11px; }
|
|
79
|
+
</style>
|
|
80
|
+
</head>
|
|
81
|
+
<body>
|
|
82
|
+
<div class="app">
|
|
83
|
+
<header class="topbar">
|
|
84
|
+
<a class="logo" href="/dashboard.html"><div class="logo-mark">OR</div>Orbiter</a>
|
|
85
|
+
<div class="topbar-right">
|
|
86
|
+
<button class="search-trigger" id="search-btn" title="Search (⌘K)">
|
|
87
|
+
<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>
|
|
88
|
+
Search <kbd>⌘K</kbd>
|
|
89
|
+
</button>
|
|
90
|
+
<button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
|
|
91
|
+
<span class="user" id="topbar-user"></span>
|
|
92
|
+
<span class="logout" id="logout-btn">Sign out</span>
|
|
93
|
+
</div>
|
|
94
|
+
</header>
|
|
95
|
+
<nav class="sidebar">
|
|
96
|
+
<div class="nav-section">Content</div>
|
|
97
|
+
<a class="nav-item" href="/dashboard.html"><span class="nav-icon">◈</span>Dashboard</a>
|
|
98
|
+
<a class="nav-item" href="/collections.html"><span class="nav-icon">⊞</span>Collections</a>
|
|
99
|
+
<div class="nav-section">Assets</div>
|
|
100
|
+
<a class="nav-item" href="/media.html"><span class="nav-icon">⊟</span>Media</a>
|
|
101
|
+
<div class="nav-section">System</div>
|
|
102
|
+
<a class="nav-item" href="/schema.html"><span class="nav-icon">◈</span>Schema</a>
|
|
103
|
+
<a class="nav-item" href="/build.html"><span class="nav-icon">▲</span>Build</a>
|
|
104
|
+
<a class="nav-item" href="/settings.html"><span class="nav-icon">◎</span>Settings</a>
|
|
105
|
+
<a class="nav-item admin-only" href="/users.html" style="display:none"><span class="nav-icon">◉</span>Users</a>
|
|
106
|
+
<div class="sidebar-footer">
|
|
107
|
+
<div class="pod-name" id="pod-name">content.pod</div>
|
|
108
|
+
<div class="pod-info" id="pod-info"></div>
|
|
109
|
+
<div class="pod-status"><span class="pod-dot"></span>pod synced</div>
|
|
110
|
+
</div>
|
|
111
|
+
</nav>
|
|
112
|
+
<main class="main">
|
|
113
|
+
<div class="page-header">
|
|
114
|
+
<h1 class="page-title">Import</h1>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="import-wrap" id="import-root">
|
|
117
|
+
<div class="spinner-wrap">Loading…</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<script type="module">
|
|
121
|
+
// ── Auth ──────────────────────────────────────────────────────────────────
|
|
122
|
+
const me = await fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()).catch(() => null);
|
|
123
|
+
if (!me?.user) { location.replace('/login.html'); }
|
|
124
|
+
document.getElementById('topbar-user').textContent = me.user.username;
|
|
125
|
+
if (me.user.role === 'admin') {
|
|
126
|
+
document.querySelectorAll('.admin-only').forEach(el => el.style.display = '');
|
|
127
|
+
}
|
|
128
|
+
document.getElementById('logout-btn').addEventListener('click', async () => {
|
|
129
|
+
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
|
130
|
+
location.replace('/login.html');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── Load sidebar info ─────────────────────────────────────────────────────
|
|
134
|
+
const info = await fetch('/api/info', { credentials: 'include' }).then(r => r.json()).catch(() => ({ pod: {}, collections: [] }));
|
|
135
|
+
document.getElementById('pod-name').textContent = info.pod?.name ?? 'content.pod';
|
|
136
|
+
document.getElementById('pod-info').textContent = info.pod?.version ? `v${info.pod.version} format` : '';
|
|
137
|
+
|
|
138
|
+
// Load sidebar collection nav via sidebar.js — it reads from /api/info automatically
|
|
139
|
+
const collections = info.collections ?? [];
|
|
140
|
+
|
|
141
|
+
// ── State ─────────────────────────────────────────────────────────────────
|
|
142
|
+
let phase = 'upload'; // 'upload' | 'preview' | 'done'
|
|
143
|
+
let activeTab = 'wp';
|
|
144
|
+
let importToken = null;
|
|
145
|
+
let plan = null;
|
|
146
|
+
let results = null;
|
|
147
|
+
let errorMsg = '';
|
|
148
|
+
|
|
149
|
+
const root = document.getElementById('import-root');
|
|
150
|
+
|
|
151
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
152
|
+
function esc(s) { return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
153
|
+
|
|
154
|
+
function renderError(msg) {
|
|
155
|
+
return msg ? `<div class="err-banner">${esc(msg)}</div>` : '';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Phase: Upload ─────────────────────────────────────────────────────────
|
|
159
|
+
function renderUpload() {
|
|
160
|
+
root.innerHTML = `
|
|
161
|
+
<div class="import-head">
|
|
162
|
+
<div class="import-sub">Import content from WordPress (WXR) or Markdown files into your pod.</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
${renderError(errorMsg)}
|
|
166
|
+
|
|
167
|
+
<div class="import-tabs">
|
|
168
|
+
<button class="import-tab ${activeTab === 'wp' ? 'active' : ''}" id="tab-wp">Import WordPress</button>
|
|
169
|
+
<button class="import-tab ${activeTab === 'md' ? 'active' : ''}" id="tab-md">Import Markdown</button>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div id="pane-wp" style="${activeTab !== 'wp' ? 'display:none' : ''}">
|
|
173
|
+
<div class="section">
|
|
174
|
+
<div class="section-head">WordPress export file</div>
|
|
175
|
+
<div class="drop-zone" id="drop-zone-wp">
|
|
176
|
+
<input type="file" id="wxr-file" accept=".xml,text/xml,application/xml" />
|
|
177
|
+
<div class="drop-icon">⊞</div>
|
|
178
|
+
<div class="drop-label">Drop .xml file here or click to browse</div>
|
|
179
|
+
<div class="drop-sub">Export your WordPress site via Tools → Export → All content.</div>
|
|
180
|
+
<div class="drop-chosen" id="drop-chosen-wp"></div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
<div class="import-actions">
|
|
184
|
+
<button class="btn-save" id="btn-analyze">Analyze file</button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div id="pane-md" style="${activeTab !== 'md' ? 'display:none' : ''}">
|
|
189
|
+
<div class="section">
|
|
190
|
+
<div class="section-head">Target collection</div>
|
|
191
|
+
<select id="md-collection" style="width:100%;background:var(--bg0);border:1px solid var(--line);border-radius:var(--radius);padding:8px 10px;color:var(--heading);font-family:var(--mono);font-size:12px;outline:none;appearance:none;margin-bottom:16px;">
|
|
192
|
+
<option value="">— select collection —</option>
|
|
193
|
+
${collections.map(c => `<option value="${esc(c.id)}">${esc(c.label)}</option>`).join('')}
|
|
194
|
+
</select>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="section">
|
|
197
|
+
<div class="section-head">Markdown files</div>
|
|
198
|
+
<div class="drop-zone" id="drop-zone-md">
|
|
199
|
+
<input type="file" id="md-files" accept=".md,text/markdown" multiple />
|
|
200
|
+
<div class="drop-icon">◈</div>
|
|
201
|
+
<div class="drop-label">Drop .md files here or click to browse</div>
|
|
202
|
+
<div class="drop-sub">Frontmatter (--- YAML ---) is parsed automatically. Fields map to collection schema.</div>
|
|
203
|
+
<div class="drop-chosen" id="drop-chosen-md"></div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="section">
|
|
207
|
+
<div class="section-head">On duplicate slug</div>
|
|
208
|
+
<div class="radio-group" id="md-dup-group">
|
|
209
|
+
<div class="radio-pill">
|
|
210
|
+
<input type="radio" name="md_dup" id="md-dup-skip" value="skip" checked />
|
|
211
|
+
<label for="md-dup-skip">Skip</label>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="radio-pill">
|
|
214
|
+
<input type="radio" name="md_dup" id="md-dup-overwrite" value="overwrite" />
|
|
215
|
+
<label for="md-dup-overwrite">Overwrite</label>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="import-actions">
|
|
220
|
+
<button class="btn-save" id="btn-import-md">Import Markdown</button>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
`;
|
|
224
|
+
wireUpload();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Phase: Preview ────────────────────────────────────────────────────────
|
|
228
|
+
function renderPreview() {
|
|
229
|
+
const typeRows = plan.types.map(t => `
|
|
230
|
+
<div class="type-row" data-type="${esc(t.postType)}">
|
|
231
|
+
<div class="ck-wrap checked" data-type="${esc(t.postType)}">
|
|
232
|
+
<div class="ck-box">✓</div>
|
|
233
|
+
</div>
|
|
234
|
+
<div>
|
|
235
|
+
<div class="type-name">${t.postType === 'post' ? 'Posts' : t.postType === 'page' ? 'Pages' : esc(t.postType)}</div>
|
|
236
|
+
<div class="type-col-id">→ collection: ${esc(t.collectionId)}</div>
|
|
237
|
+
</div>
|
|
238
|
+
<div class="type-count">${t.count} entries<br/><span style="font-size:9px;color:var(--jade)">${t.published} published</span></div>
|
|
239
|
+
<div class="type-badge">${esc(t.collectionId)}</div>
|
|
240
|
+
</div>`).join('');
|
|
241
|
+
|
|
242
|
+
const mediaSection = plan.mediaCount > 0 ? `
|
|
243
|
+
<div class="section">
|
|
244
|
+
<div class="section-head">Media</div>
|
|
245
|
+
<div class="opt-row">
|
|
246
|
+
<div class="ck-wrap checked" id="ck-media">
|
|
247
|
+
<div class="ck-box">✓</div>
|
|
248
|
+
<span class="ck-txt">Download ${plan.mediaCount} media file${plan.mediaCount !== 1 ? 's' : ''} from WordPress
|
|
249
|
+
<span class="opt-desc">Fetches each attachment from the original URL and stores it in your pod.</span>
|
|
250
|
+
</span>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
</div>` : '';
|
|
254
|
+
|
|
255
|
+
root.innerHTML = `
|
|
256
|
+
<div class="import-head">
|
|
257
|
+
<div class="import-title">Preview import</div>
|
|
258
|
+
<div class="import-sub">Review what will be imported, then confirm.</div>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div class="site-banner">
|
|
262
|
+
<div class="site-banner-icon">◈</div>
|
|
263
|
+
<div>
|
|
264
|
+
<div class="site-banner-name">${esc(plan.site.title || 'WordPress Site')}</div>
|
|
265
|
+
${plan.site.url ? `<div class="site-banner-url">${esc(plan.site.url)}</div>` : ''}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<div class="section">
|
|
270
|
+
<div class="section-head">Content types</div>
|
|
271
|
+
${typeRows}
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
${mediaSection}
|
|
275
|
+
|
|
276
|
+
<div class="section">
|
|
277
|
+
<div class="section-head">On duplicate slug</div>
|
|
278
|
+
<div class="opt-row">
|
|
279
|
+
<span class="opt-label">If a slug already exists in the collection</span>
|
|
280
|
+
<div class="radio-group" id="dup-group">
|
|
281
|
+
<div class="radio-pill">
|
|
282
|
+
<input type="radio" name="on_dup" id="dup-skip" value="skip" checked />
|
|
283
|
+
<label for="dup-skip">Skip</label>
|
|
284
|
+
</div>
|
|
285
|
+
<div class="radio-pill">
|
|
286
|
+
<input type="radio" name="on_dup" id="dup-overwrite" value="overwrite" />
|
|
287
|
+
<label for="dup-overwrite">Overwrite</label>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<div class="import-actions">
|
|
294
|
+
<button class="btn btn-ghost" id="btn-back">← Back</button>
|
|
295
|
+
<button class="btn-save" id="btn-execute">Start import</button>
|
|
296
|
+
</div>
|
|
297
|
+
`;
|
|
298
|
+
wirePreview();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Phase: Done ───────────────────────────────────────────────────────────
|
|
302
|
+
function renderDone() {
|
|
303
|
+
let rows = '';
|
|
304
|
+
if (results.type === 'markdown') {
|
|
305
|
+
rows += `<div class="result-row"><span class="result-num result-ok">${results.imported}</span><span>entries imported</span></div>`;
|
|
306
|
+
if (results.skipped > 0)
|
|
307
|
+
rows += `<div class="result-row"><span class="result-num" style="color:var(--muted)">${results.skipped}</span><span style="color:var(--muted)">skipped (duplicate slug)</span></div>`;
|
|
308
|
+
} else {
|
|
309
|
+
if (results.collections?.length > 0)
|
|
310
|
+
rows += `<div class="result-row"><span class="result-ok">◈</span><span>Collections created: <strong>${results.collections.map(esc).join(', ')}</strong></span></div>`;
|
|
311
|
+
rows += `<div class="result-row"><span class="result-num result-ok">${results.imported}</span><span>entries imported</span></div>`;
|
|
312
|
+
if (results.overwritten > 0)
|
|
313
|
+
rows += `<div class="result-row"><span class="result-num result-warn">${results.overwritten}</span><span style="color:var(--gold)">entries overwritten</span></div>`;
|
|
314
|
+
if (results.skipped > 0)
|
|
315
|
+
rows += `<div class="result-row"><span class="result-num" style="color:var(--muted)">${results.skipped}</span><span style="color:var(--muted)">skipped (duplicate slug)</span></div>`;
|
|
316
|
+
if (results.mediaOk > 0)
|
|
317
|
+
rows += `<div class="result-row"><span class="result-num result-ok">${results.mediaOk}</span><span>media files downloaded</span></div>`;
|
|
318
|
+
if (results.mediaFailed > 0)
|
|
319
|
+
rows += `<div class="result-row"><span class="result-num result-warn">${results.mediaFailed}</span><span style="color:var(--gold)">media files failed</span></div>`;
|
|
320
|
+
const errs = results.errors ?? [];
|
|
321
|
+
if (errs.length > 0) {
|
|
322
|
+
rows += `<div style="margin-top:16px"><div class="section-head">Errors (${errs.length})</div>`;
|
|
323
|
+
errs.slice(0, 20).forEach(e => { rows += `<div class="result-err">${esc(e)}</div>`; });
|
|
324
|
+
if (errs.length > 20) rows += `<div class="result-err">… and ${errs.length - 20} more</div>`;
|
|
325
|
+
rows += '</div>';
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
root.innerHTML = `
|
|
330
|
+
<div class="import-head">
|
|
331
|
+
<div class="import-title">Import complete</div>
|
|
332
|
+
<div class="import-sub">Your content has been imported into the pod.</div>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
<div class="section">
|
|
336
|
+
<div class="section-head">Results</div>
|
|
337
|
+
${rows}
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<div class="import-actions">
|
|
341
|
+
<a class="btn-save" href="/collections.html">Go to Collections</a>
|
|
342
|
+
<button class="btn btn-ghost" id="btn-another">Import another</button>
|
|
343
|
+
</div>
|
|
344
|
+
`;
|
|
345
|
+
|
|
346
|
+
document.getElementById('btn-another').addEventListener('click', () => {
|
|
347
|
+
phase = 'upload'; errorMsg = ''; activeTab = 'wp'; render();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Wire: upload phase ────────────────────────────────────────────────────
|
|
352
|
+
function wireUpload() {
|
|
353
|
+
// Tab switching
|
|
354
|
+
document.getElementById('tab-wp').addEventListener('click', () => { activeTab = 'wp'; render(); });
|
|
355
|
+
document.getElementById('tab-md').addEventListener('click', () => { activeTab = 'md'; render(); });
|
|
356
|
+
|
|
357
|
+
// WP drop zone
|
|
358
|
+
const dzWp = document.getElementById('drop-zone-wp');
|
|
359
|
+
const inpWp = document.getElementById('wxr-file');
|
|
360
|
+
const lblWp = document.getElementById('drop-chosen-wp');
|
|
361
|
+
if (inpWp && lblWp) {
|
|
362
|
+
inpWp.addEventListener('change', () => {
|
|
363
|
+
const f = inpWp.files?.[0];
|
|
364
|
+
if (f) { lblWp.textContent = f.name; lblWp.style.display = 'block'; }
|
|
365
|
+
});
|
|
366
|
+
dzWp.addEventListener('dragover', e => { e.preventDefault(); dzWp.classList.add('drag'); });
|
|
367
|
+
dzWp.addEventListener('dragleave', () => dzWp.classList.remove('drag'));
|
|
368
|
+
dzWp.addEventListener('drop', e => {
|
|
369
|
+
e.preventDefault(); dzWp.classList.remove('drag');
|
|
370
|
+
const f = e.dataTransfer?.files[0];
|
|
371
|
+
if (f && inpWp) {
|
|
372
|
+
const dt = new DataTransfer(); dt.items.add(f); inpWp.files = dt.files;
|
|
373
|
+
lblWp.textContent = f.name; lblWp.style.display = 'block';
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Markdown drop zone
|
|
379
|
+
const dzMd = document.getElementById('drop-zone-md');
|
|
380
|
+
const inpMd = document.getElementById('md-files');
|
|
381
|
+
const lblMd = document.getElementById('drop-chosen-md');
|
|
382
|
+
if (inpMd && lblMd) {
|
|
383
|
+
inpMd.addEventListener('change', () => {
|
|
384
|
+
const count = inpMd.files?.length ?? 0;
|
|
385
|
+
if (count > 0) {
|
|
386
|
+
lblMd.textContent = count === 1 ? inpMd.files[0].name : `${count} files selected`;
|
|
387
|
+
lblMd.style.display = 'block';
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
dzMd.addEventListener('dragover', e => { e.preventDefault(); dzMd.classList.add('drag'); });
|
|
391
|
+
dzMd.addEventListener('dragleave', () => dzMd.classList.remove('drag'));
|
|
392
|
+
dzMd.addEventListener('drop', e => {
|
|
393
|
+
e.preventDefault(); dzMd.classList.remove('drag');
|
|
394
|
+
const files = e.dataTransfer?.files;
|
|
395
|
+
if (files?.length && inpMd) {
|
|
396
|
+
const dt = new DataTransfer();
|
|
397
|
+
Array.from(files).forEach(f => dt.items.add(f));
|
|
398
|
+
inpMd.files = dt.files;
|
|
399
|
+
lblMd.textContent = files.length === 1 ? files[0].name : `${files.length} files selected`;
|
|
400
|
+
lblMd.style.display = 'block';
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Analyze WXR
|
|
406
|
+
document.getElementById('btn-analyze')?.addEventListener('click', async () => {
|
|
407
|
+
const f = document.getElementById('wxr-file')?.files?.[0];
|
|
408
|
+
if (!f) { errorMsg = 'Please select a WordPress export file (.xml).'; render(); return; }
|
|
409
|
+
|
|
410
|
+
const btn = document.getElementById('btn-analyze');
|
|
411
|
+
btn.disabled = true; btn.textContent = 'Analyzing…';
|
|
412
|
+
|
|
413
|
+
const fd = new FormData();
|
|
414
|
+
fd.append('wxr_file', f);
|
|
415
|
+
|
|
416
|
+
const res = await fetch('/api/import/analyze', { method: 'POST', credentials: 'include', body: fd });
|
|
417
|
+
const data = await res.json();
|
|
418
|
+
|
|
419
|
+
if (!res.ok) { errorMsg = data.error ?? 'Analysis failed'; phase = 'upload'; render(); return; }
|
|
420
|
+
|
|
421
|
+
importToken = data.token;
|
|
422
|
+
plan = data.plan;
|
|
423
|
+
errorMsg = '';
|
|
424
|
+
phase = 'preview';
|
|
425
|
+
render();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// Import Markdown
|
|
429
|
+
document.getElementById('btn-import-md')?.addEventListener('click', async () => {
|
|
430
|
+
const files = document.getElementById('md-files')?.files;
|
|
431
|
+
const col = document.getElementById('md-collection')?.value;
|
|
432
|
+
const dup = document.querySelector('input[name="md_dup"]:checked')?.value ?? 'skip';
|
|
433
|
+
|
|
434
|
+
if (!files?.length) { errorMsg = 'Please select at least one .md file.'; render(); return; }
|
|
435
|
+
if (!col) { errorMsg = 'Please select a target collection.'; render(); return; }
|
|
436
|
+
|
|
437
|
+
const btn = document.getElementById('btn-import-md');
|
|
438
|
+
btn.disabled = true; btn.textContent = 'Importing…';
|
|
439
|
+
|
|
440
|
+
const fd = new FormData();
|
|
441
|
+
fd.append('md_collection', col);
|
|
442
|
+
fd.append('on_duplicate', dup);
|
|
443
|
+
Array.from(files).forEach(f => fd.append('md_files', f));
|
|
444
|
+
|
|
445
|
+
const res = await fetch('/api/import/markdown', { method: 'POST', credentials: 'include', body: fd });
|
|
446
|
+
const data = await res.json();
|
|
447
|
+
|
|
448
|
+
if (!res.ok) { errorMsg = data.error ?? 'Import failed'; render(); return; }
|
|
449
|
+
|
|
450
|
+
results = data;
|
|
451
|
+
errorMsg = '';
|
|
452
|
+
phase = 'done';
|
|
453
|
+
render();
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Wire: preview phase ───────────────────────────────────────────────────
|
|
458
|
+
function wirePreview() {
|
|
459
|
+
// Custom checkboxes
|
|
460
|
+
document.querySelectorAll('.ck-wrap').forEach(wrap => {
|
|
461
|
+
wrap.addEventListener('click', () => {
|
|
462
|
+
wrap.classList.toggle('checked');
|
|
463
|
+
wrap.querySelector('.ck-box').textContent = wrap.classList.contains('checked') ? '✓' : '';
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
document.getElementById('btn-back').addEventListener('click', () => {
|
|
468
|
+
phase = 'upload'; render();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
document.getElementById('btn-execute').addEventListener('click', async () => {
|
|
472
|
+
const selectedTypes = Array.from(document.querySelectorAll('.ck-wrap.checked[data-type]'))
|
|
473
|
+
.map(el => el.dataset.type);
|
|
474
|
+
if (!selectedTypes.length) { alert('Please select at least one content type.'); return; }
|
|
475
|
+
|
|
476
|
+
const downloadMedia = document.getElementById('ck-media')?.classList.contains('checked') ?? false;
|
|
477
|
+
const onDuplicate = document.querySelector('input[name="on_dup"]:checked')?.value ?? 'skip';
|
|
478
|
+
|
|
479
|
+
const btn = document.getElementById('btn-execute');
|
|
480
|
+
btn.disabled = true; btn.textContent = 'Importing…';
|
|
481
|
+
|
|
482
|
+
const res = await fetch('/api/import/execute', {
|
|
483
|
+
method: 'POST', credentials: 'include',
|
|
484
|
+
headers: { 'Content-Type': 'application/json' },
|
|
485
|
+
body: JSON.stringify({ token: importToken, selectedTypes, downloadMedia, onDuplicate }),
|
|
486
|
+
});
|
|
487
|
+
const data = await res.json();
|
|
488
|
+
|
|
489
|
+
if (!res.ok) { errorMsg = data.error ?? 'Import failed'; phase = 'upload'; render(); return; }
|
|
490
|
+
|
|
491
|
+
results = data;
|
|
492
|
+
errorMsg = '';
|
|
493
|
+
phase = 'done';
|
|
494
|
+
render();
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Main render dispatcher ────────────────────────────────────────────────
|
|
499
|
+
function render() {
|
|
500
|
+
if (phase === 'upload') renderUpload();
|
|
501
|
+
if (phase === 'preview') renderPreview();
|
|
502
|
+
if (phase === 'done') renderDone();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
render();
|
|
506
|
+
</script>
|
|
507
|
+
</main>
|
|
508
|
+
</div>
|
|
509
|
+
|
|
510
|
+
<script src="/search.js"></script>
|
|
511
|
+
<script src="/sidebar.js"></script>
|
|
512
|
+
<script src="/router.js"></script>
|
|
513
|
+
</body>
|
|
514
|
+
</html>
|
|
@@ -0,0 +1,76 @@
|
|
|
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 — Login</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
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div class="login-wrap">
|
|
15
|
+
<div class="login-box">
|
|
16
|
+
<div class="login-logo">
|
|
17
|
+
<div class="login-mark"></div>
|
|
18
|
+
<div class="login-logo-mark">ORBITER</div>
|
|
19
|
+
<div class="login-logo-sub">Admin</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="login-card">
|
|
22
|
+
<h2>Sign in</h2>
|
|
23
|
+
<div class="login-error" id="error"></div>
|
|
24
|
+
<form id="login-form">
|
|
25
|
+
<div class="field">
|
|
26
|
+
<label class="label" for="username">Username</label>
|
|
27
|
+
<input class="input" id="username" name="username" type="text" autocomplete="username" autofocus required />
|
|
28
|
+
</div>
|
|
29
|
+
<div class="field">
|
|
30
|
+
<label class="label" for="password">Password</label>
|
|
31
|
+
<input class="input" id="password" name="password" type="password" autocomplete="current-password" required />
|
|
32
|
+
</div>
|
|
33
|
+
<button class="btn btn-primary" style="width:100%;margin-top:8px;justify-content:center" type="submit" id="submit-btn">
|
|
34
|
+
Sign in
|
|
35
|
+
</button>
|
|
36
|
+
</form>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<script type="module">
|
|
42
|
+
// Redirect if already logged in
|
|
43
|
+
const me = await fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()).catch(() => null);
|
|
44
|
+
if (me?.user) location.replace('/dashboard.html');
|
|
45
|
+
|
|
46
|
+
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
const btn = document.getElementById('submit-btn');
|
|
49
|
+
const error = document.getElementById('error');
|
|
50
|
+
btn.disabled = true;
|
|
51
|
+
btn.innerHTML = '<span class="spinner"></span> Signing in…';
|
|
52
|
+
error.classList.remove('visible');
|
|
53
|
+
|
|
54
|
+
const res = await fetch('/api/auth/login', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
credentials: 'include',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
username: e.target.username.value,
|
|
60
|
+
password: e.target.password.value,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
const data = await res.json();
|
|
64
|
+
|
|
65
|
+
if (res.ok) {
|
|
66
|
+
location.replace('/dashboard.html');
|
|
67
|
+
} else {
|
|
68
|
+
error.textContent = data.error ?? 'Login failed';
|
|
69
|
+
error.classList.add('visible');
|
|
70
|
+
btn.disabled = false;
|
|
71
|
+
btn.textContent = 'Sign in';
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
75
|
+
</body>
|
|
76
|
+
</html>
|