doc-center.html 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>DocCenter — VL-Code</title>
  7. <style>
  8. :root {
  9. --bg: #0d1117; --bg2: #161b22; --bg3: #21262d; --border: #30363d;
  10. --text: #e6edf3; --text2: #8b949e; --accent: #58a6ff; --green: #3fb950;
  11. --yellow: #d29922; --red: #f85149; --purple: #a371f7;
  12. --font: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
  13. }
  14. * { margin:0; padding:0; box-sizing:border-box; }
  15. body { background:var(--bg); color:var(--text); font-family:var(--font); font-size:13px; height:100vh; display:flex; flex-direction:column; }
  16. body.embed header .embed-hidden,
  17. body.embed .status-bar { display:none; }
  18. body.embed header { padding:8px 12px; }
  19. body.embed .layout { border-top:1px solid var(--border); }
  20. body.embed .sidebar { width:300px; }
  21. body.landing-embed .sidebar-actions,
  22. body.landing-embed .mode-tabs,
  23. body.landing-embed #btnSave,
  24. body.landing-embed #btnPublish { display:none !important; }
  25. body.landing-embed .toolbar { padding:8px 12px; }
  26. /* Header */
  27. header {
  28. background:var(--bg2); border-bottom:1px solid var(--border);
  29. padding:8px 16px; display:flex; align-items:center; gap:12px; flex-shrink:0;
  30. }
  31. header h1 { font-size:15px; color:var(--accent); font-weight:600; white-space:nowrap; }
  32. header .spacer { flex:1; }
  33. .hdr-btn {
  34. background:var(--bg3); color:var(--text2); border:1px solid var(--border);
  35. padding:5px 12px; border-radius:5px; cursor:pointer; font-family:var(--font); font-size:11px; white-space:nowrap;
  36. }
  37. .hdr-btn:hover { background:var(--border); color:var(--text); }
  38. .hdr-btn-primary { background:var(--accent); color:#fff; border-color:var(--accent); }
  39. .hdr-btn-primary:hover { background:#79b8ff; }
  40. .hdr-btn-green { background:var(--green); color:#fff; border-color:var(--green); }
  41. .hdr-btn-green:hover { background:#56d364; }
  42. .hdr-btn:disabled { opacity:0.4; cursor:not-allowed; }
  43. /* Layout */
  44. .layout { flex:1; display:flex; overflow:hidden; }
  45. /* Sidebar */
  46. .sidebar {
  47. width:280px; min-width:200px; background:var(--bg2); border-right:1px solid var(--border);
  48. display:flex; flex-direction:column;
  49. }
  50. .sidebar-header { padding:10px 12px 6px; }
  51. .sidebar-header h3 { font-size:11px; color:var(--text2); text-transform:uppercase; letter-spacing:1px; margin-bottom:8px; }
  52. .sidebar-controls {
  53. display:flex; align-items:center; gap:6px; margin-top:8px;
  54. }
  55. .sidebar-controls select {
  56. flex:1; background:var(--bg); border:1px solid var(--border); color:var(--text);
  57. padding:5px 8px; border-radius:5px; font-family:var(--font); font-size:11px; outline:none;
  58. }
  59. .sidebar-controls select:focus { border-color:var(--accent); }
  60. .sidebar-note { font-size:10px; color:var(--text2); line-height:1.5; margin-top:8px; }
  61. .search-box {
  62. width:100%; background:var(--bg); border:1px solid var(--border); color:var(--text);
  63. padding:6px 10px; border-radius:5px; font-family:var(--font); font-size:12px; outline:none;
  64. }
  65. .search-box:focus { border-color:var(--accent); }
  66. .search-box::placeholder { color:var(--text2); }
  67. .sidebar-actions { display:flex; gap:4px; padding:6px 12px; border-bottom:1px solid var(--border); }
  68. .sa-btn {
  69. flex:1; display:flex; align-items:center; justify-content:center; gap:4px;
  70. background:var(--bg3); color:var(--text2); border:1px solid var(--border);
  71. padding:4px 8px; border-radius:4px; cursor:pointer; font-family:var(--font); font-size:10px;
  72. }
  73. .sa-btn:hover { background:var(--border); color:var(--text); }
  74. .doc-list { flex:1; overflow-y:auto; padding:4px 0; }
  75. .doc-item {
  76. padding:8px 12px; cursor:pointer; border-left:3px solid transparent;
  77. display:flex; flex-direction:column; gap:2px;
  78. }
  79. .doc-item:hover { background:var(--bg3); }
  80. .doc-item.active { background:var(--bg3); border-left-color:var(--accent); }
  81. .doc-item .doc-name { font-size:12px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  82. .doc-item .doc-meta { display:flex; gap:6px; flex-wrap:wrap; }
  83. .doc-item .doc-ref { font-size:10px; color:var(--accent); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  84. .doc-item .doc-tags { display:flex; gap:3px; margin-top:2px; flex-wrap:wrap; }
  85. .doc-badge {
  86. font-size:10px; padding:2px 6px; border-radius:999px; border:1px solid var(--border);
  87. color:var(--text2); background:rgba(255,255,255,0.02);
  88. }
  89. .doc-badge-id { color:var(--accent); border-color:rgba(88,166,255,0.25); background:rgba(88,166,255,0.08); }
  90. .doc-badge-path { color:var(--purple); border-color:rgba(163,113,247,0.25); background:rgba(163,113,247,0.08); }
  91. .doc-badge-version { color:var(--green); border-color:rgba(63,185,80,0.25); background:rgba(63,185,80,0.08); }
  92. .doc-badge-scope-official { color:#f2cc60; border-color:rgba(242,204,96,0.25); background:rgba(242,204,96,0.08); }
  93. .doc-badge-scope-user { color:#ffab70; border-color:rgba(255,171,112,0.25); background:rgba(255,171,112,0.08); }
  94. .doc-tag {
  95. font-size:9px; padding:1px 5px; border-radius:8px;
  96. background:rgba(88,166,255,0.15); color:var(--accent);
  97. }
  98. .list-status { padding:12px; text-align:center; color:var(--text2); font-size:11px; }
  99. /* Main content */
  100. .main-content { flex:1; display:flex; flex-direction:column; overflow:hidden; }
  101. /* Toolbar */
  102. .toolbar {
  103. background:var(--bg2); border-bottom:1px solid var(--border);
  104. padding:6px 16px; display:flex; align-items:center; gap:8px; flex-shrink:0;
  105. }
  106. .toolbar .doc-title { font-size:13px; font-weight:600; color:var(--text); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  107. .toolbar .doc-id { font-size:10px; color:var(--text2); margin-right:8px; }
  108. .toolbar .doc-ref { font-size:10px; color:var(--accent); max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  109. .mode-tabs { display:flex; gap:2px; }
  110. .mode-tab {
  111. padding:4px 12px; border-radius:4px; cursor:pointer; font-size:11px;
  112. color:var(--text2); background:transparent; border:1px solid transparent;
  113. font-family:var(--font);
  114. }
  115. .mode-tab:hover { color:var(--text); }
  116. .mode-tab.active { background:var(--bg3); color:var(--text); border-color:var(--border); }
  117. /* Editor / Viewer */
  118. .content-area { flex:1; overflow:auto; position:relative; }
  119. .content-area textarea {
  120. width:100%; height:100%; background:var(--bg); color:var(--text); border:none;
  121. padding:16px 20px; font-family:var(--font); font-size:13px; line-height:1.6;
  122. resize:none; outline:none; tab-size:2;
  123. }
  124. .content-area .md-view {
  125. padding:16px 24px; line-height:1.7; max-width:900px;
  126. }
  127. .md-view h1 { font-size:22px; margin:16px 0 8px; color:var(--accent); border-bottom:1px solid var(--border); padding-bottom:6px; }
  128. .md-view h2 { font-size:18px; margin:14px 0 6px; color:var(--text); }
  129. .md-view h3 { font-size:15px; margin:12px 0 4px; color:var(--text); }
  130. .md-view h4 { font-size:13px; margin:10px 0 4px; color:var(--text2); }
  131. .md-view p { margin:6px 0; }
  132. .md-view a { color:var(--accent); text-decoration:none; }
  133. .md-view a:hover { text-decoration:underline; }
  134. .md-view code {
  135. background:var(--bg3); padding:1px 5px; border-radius:3px; font-size:12px; color:var(--yellow);
  136. }
  137. .md-view pre {
  138. background:var(--bg3); border:1px solid var(--border); border-radius:6px;
  139. padding:12px 16px; margin:8px 0; overflow-x:auto; font-size:12px; line-height:1.5;
  140. }
  141. .md-view pre code { background:none; padding:0; color:var(--text); }
  142. .md-view blockquote {
  143. border-left:3px solid var(--accent); padding:4px 12px; margin:8px 0;
  144. color:var(--text2); background:rgba(88,166,255,0.05);
  145. }
  146. .md-view ul, .md-view ol { padding-left:20px; margin:6px 0; }
  147. .md-view li { margin:3px 0; }
  148. .md-view table { border-collapse:collapse; margin:8px 0; width:100%; }
  149. .md-view th, .md-view td { border:1px solid var(--border); padding:6px 10px; text-align:left; font-size:12px; }
  150. .md-view th { background:var(--bg3); color:var(--text2); font-weight:600; }
  151. .md-view hr { border:none; border-top:1px solid var(--border); margin:12px 0; }
  152. .md-view img { max-width:100%; border-radius:4px; }
  153. /* Empty state */
  154. .empty-state {
  155. display:flex; flex-direction:column; align-items:center; justify-content:center;
  156. height:100%; color:var(--text2); gap:8px;
  157. }
  158. .empty-state .icon { font-size:48px; opacity:0.3; }
  159. .empty-state p { font-size:13px; }
  160. /* Status bar */
  161. .status-bar {
  162. background:var(--bg2); border-top:1px solid var(--border);
  163. padding:4px 16px; font-size:10px; color:var(--text2);
  164. display:flex; align-items:center; gap:12px; flex-shrink:0;
  165. }
  166. .status-bar .modified { color:var(--yellow); }
  167. .status-bar .saved { color:var(--green); }
  168. /* Toast */
  169. .toast {
  170. position:fixed; bottom:20px; right:20px; padding:10px 20px;
  171. background:var(--bg3); border:1px solid var(--border); border-radius:6px;
  172. color:var(--text); font-size:12px; z-index:1000;
  173. opacity:0; transform:translateY(10px); transition:all 0.2s;
  174. }
  175. .toast.show { opacity:1; transform:translateY(0); }
  176. .toast.error { border-color:var(--red); color:var(--red); }
  177. .toast.success { border-color:var(--green); color:var(--green); }
  178. /* Scrollbar */
  179. ::-webkit-scrollbar { width:6px; height:6px; }
  180. ::-webkit-scrollbar-track { background:transparent; }
  181. ::-webkit-scrollbar-thumb { background:var(--bg3); border-radius:3px; }
  182. ::-webkit-scrollbar-thumb:hover { background:var(--border); }
  183. /* Dialog overlay */
  184. .dialog-overlay {
  185. position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.6);
  186. display:none; align-items:center; justify-content:center; z-index:500;
  187. }
  188. .dialog-overlay.open { display:flex; }
  189. .dialog {
  190. background:var(--bg2); border:1px solid var(--border); border-radius:8px;
  191. padding:20px; min-width:360px; max-width:500px;
  192. }
  193. .dialog h3 { font-size:14px; margin-bottom:12px; color:var(--text); }
  194. .dialog label { display:block; font-size:11px; color:var(--text2); margin-bottom:4px; margin-top:10px; }
  195. .dialog input, .dialog textarea {
  196. width:100%; background:var(--bg); border:1px solid var(--border); color:var(--text);
  197. padding:6px 10px; border-radius:4px; font-family:var(--font); font-size:12px; outline:none;
  198. }
  199. .dialog input:focus, .dialog textarea:focus { border-color:var(--accent); }
  200. .dialog .dialog-actions { display:flex; gap:8px; justify-content:flex-end; margin-top:16px; }
  201. </style>
  202. </head>
  203. <body>
  204. <header>
  205. <h1>DocCenter</h1>
  206. <span class="hdr-btn embed-hidden" onclick="location.href='/'">Back to IDE</span>
  207. <div class="spacer"></div>
  208. <button class="hdr-btn" id="btnRefresh" onclick="refreshList()">Refresh</button>
  209. <button class="hdr-btn hdr-btn-primary" id="btnSave" onclick="saveDoc()" disabled>Save</button>
  210. <button class="hdr-btn hdr-btn-green" id="btnPublish" onclick="publishDoc()" disabled>Publish</button>
  211. </header>
  212. <div class="layout">
  213. <!-- Sidebar: Document List -->
  214. <div class="sidebar">
  215. <div class="sidebar-header">
  216. <h3>Documents</h3>
  217. <input type="text" class="search-box" id="searchInput" placeholder="Search name / path / ID..." oninput="filterDocs()">
  218. <div class="sidebar-controls">
  219. <select id="sortSelect" onchange="renderVisibleDocs()">
  220. <option value="path">Sort: Slot ↑ / ID ↓</option>
  221. <option value="name">Sort: Name</option>
  222. <option value="id">Sort: Doc ID</option>
  223. <option value="updated">Sort: Updated</option>
  224. <option value="version">Sort: Version</option>
  225. </select>
  226. </div>
  227. <div class="sidebar-note">这里先看 <code>Slot / Path</code>,再填右侧显示的 <code>Doc ID</code>。真正配置到 VLCode 里的永远是 Doc ID,不是 Path。</div>
  228. </div>
  229. <div class="sidebar-actions">
  230. <div class="sa-btn" onclick="showCreateDialog()">+ New Doc</div>
  231. <div class="sa-btn" onclick="loadTags()">Tags</div>
  232. </div>
  233. <div class="doc-list" id="docList">
  234. <div class="list-status">Loading...</div>
  235. </div>
  236. </div>
  237. <!-- Main Content -->
  238. <div class="main-content">
  239. <div class="toolbar" id="toolbar" style="display:none;">
  240. <span class="doc-id" id="docIdLabel"></span>
  241. <span class="doc-ref" id="docRefLabel"></span>
  242. <span class="doc-title" id="docTitleLabel"></span>
  243. <button class="hdr-btn" id="btnCopyDocId" onclick="copyCurrentDocId()" disabled>Copy ID</button>
  244. <button class="hdr-btn" id="btnCopyDocLink" onclick="copyCurrentDocLink()" disabled>Copy Link</button>
  245. <div class="mode-tabs">
  246. <div class="mode-tab active" data-mode="view" onclick="setMode('view')">View</div>
  247. <div class="mode-tab" data-mode="edit" onclick="setMode('edit')">Edit</div>
  248. </div>
  249. </div>
  250. <div class="content-area" id="contentArea">
  251. <div class="empty-state">
  252. <div class="icon">&#128196;</div>
  253. <p>Select a document to view or edit</p>
  254. </div>
  255. </div>
  256. </div>
  257. </div>
  258. <div class="status-bar">
  259. <span id="statusText">Ready</span>
  260. <div class="spacer" style="flex:1;"></div>
  261. <span id="statusModified"></span>
  262. <span id="statusChars"></span>
  263. </div>
  264. <!-- Create Document Dialog -->
  265. <div class="dialog-overlay" id="createDialog">
  266. <div class="dialog">
  267. <h3>Create New Document</h3>
  268. <label>Document Name</label>
  269. <input type="text" id="newDocName" placeholder="My Document">
  270. <label>Path (numeric ID)</label>
  271. <input type="number" id="newDocPath" placeholder="110" min="1">
  272. <label>Initial Content (optional)</label>
  273. <textarea id="newDocContent" rows="4" placeholder="# Title"></textarea>
  274. <div class="dialog-actions">
  275. <button class="hdr-btn" onclick="closeCreateDialog()">Cancel</button>
  276. <button class="hdr-btn hdr-btn-primary" onclick="createDoc()">Create</button>
  277. </div>
  278. </div>
  279. </div>
  280. <!-- Publish Dialog -->
  281. <div class="dialog-overlay" id="publishDialog">
  282. <div class="dialog">
  283. <h3>Publish Version</h3>
  284. <label>Version Message</label>
  285. <input type="text" id="publishMessage" placeholder="Describe this version...">
  286. <div class="dialog-actions">
  287. <button class="hdr-btn" onclick="closePublishDialog()">Cancel</button>
  288. <button class="hdr-btn hdr-btn-green" onclick="confirmPublish()">Publish</button>
  289. </div>
  290. </div>
  291. </div>
  292. <div class="toast" id="toast"></div>
  293. <script>
  294. // --- State ---
  295. let allDocs = [];
  296. let currentDoc = null; // { id, name, path, content, ... }
  297. let originalContent = ''; // track if modified
  298. let mode = 'view'; // 'view' | 'edit'
  299. const urlParams = new URLSearchParams(window.location.search);
  300. const embedMode = urlParams.get('embed') || '';
  301. const DOC_REF_PREFIX = 'vl://doc/';
  302. let pendingDocId = normalizeDocRefInput(urlParams.get('docId') || urlParams.get('ref'));
  303. function normalizeDocRefInput(value) {
  304. if (typeof value === 'number') {
  305. return Number.isInteger(value) && value > 0 ? value : null;
  306. }
  307. if (typeof value !== 'string') return null;
  308. const trimmed = value.trim();
  309. if (!trimmed) return null;
  310. if (/^\d+$/.test(trimmed)) {
  311. const n = parseInt(trimmed, 10);
  312. return Number.isInteger(n) && n > 0 ? n : null;
  313. }
  314. if (trimmed.toLowerCase().startsWith(DOC_REF_PREFIX)) {
  315. return normalizeDocRefInput(trimmed.slice(DOC_REF_PREFIX.length));
  316. }
  317. const inlineMatch = trimmed.match(/(?:^|[?&#])(?:docId|id|ref)=([^&#]+)/i);
  318. if (inlineMatch?.[1]) {
  319. try {
  320. return normalizeDocRefInput(decodeURIComponent(inlineMatch[1]));
  321. } catch {
  322. return normalizeDocRefInput(inlineMatch[1]);
  323. }
  324. }
  325. try {
  326. const parsed = new URL(trimmed, window.location.origin);
  327. const docId = parsed.searchParams.get('docId')
  328. || parsed.searchParams.get('id')
  329. || parsed.searchParams.get('ref');
  330. if (docId) return normalizeDocRefInput(docId);
  331. } catch {}
  332. return null;
  333. }
  334. function formatDocRef(value) {
  335. const docId = normalizeDocRefInput(value);
  336. return docId ? `${DOC_REF_PREFIX}${docId}` : '';
  337. }
  338. function formatDocHref(value, { absolute = false } = {}) {
  339. const docId = normalizeDocRefInput(value);
  340. if (!docId) return '';
  341. const path = `/doc-center.html?docId=${docId}`;
  342. return absolute ? `${window.location.origin}${path}` : path;
  343. }
  344. function isOfficialDoc(doc = {}) {
  345. const id = parseInt(doc.id ?? doc._id, 10);
  346. return Number.isInteger(id) && id > 0 && id < 1000;
  347. }
  348. function docScope(doc = {}) {
  349. return isOfficialDoc(doc)
  350. ? { label: 'Official', className: 'doc-badge-scope-official' }
  351. : { label: 'User', className: 'doc-badge-scope-user' };
  352. }
  353. function parseDocTimestamp(doc = {}) {
  354. const raw = doc.updatedAt || doc.updated_at || doc.createdAt || doc.created_at || '';
  355. const ts = raw ? new Date(raw).getTime() : 0;
  356. return Number.isFinite(ts) ? ts : 0;
  357. }
  358. function parseDocVersion(doc = {}) {
  359. return parseInt(doc.latestVersion || doc.latest_version || doc.version || 0, 10) || 0;
  360. }
  361. function sortDocs(docs) {
  362. const sortKey = document.getElementById('sortSelect')?.value || 'path';
  363. const items = [...docs];
  364. if (sortKey === 'path') {
  365. return items.sort((a, b) =>
  366. (parseInt(a.path || '0', 10) || Number.MAX_SAFE_INTEGER) - (parseInt(b.path || '0', 10) || Number.MAX_SAFE_INTEGER)
  367. || (parseInt(b.id || b._id || '0', 10) || 0) - (parseInt(a.id || a._id || '0', 10) || 0)
  368. || String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN', { numeric: true, sensitivity: 'base' })
  369. );
  370. }
  371. if (sortKey === 'id') {
  372. return items.sort((a, b) =>
  373. (parseInt(a.id || a._id || '0', 10) || Number.MAX_SAFE_INTEGER) - (parseInt(b.id || b._id || '0', 10) || Number.MAX_SAFE_INTEGER)
  374. || String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN', { numeric: true, sensitivity: 'base' })
  375. );
  376. }
  377. if (sortKey === 'updated') {
  378. return items.sort((a, b) =>
  379. parseDocTimestamp(b) - parseDocTimestamp(a)
  380. || String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN', { numeric: true, sensitivity: 'base' })
  381. );
  382. }
  383. if (sortKey === 'version') {
  384. return items.sort((a, b) =>
  385. parseDocVersion(b) - parseDocVersion(a)
  386. || String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN', { numeric: true, sensitivity: 'base' })
  387. );
  388. }
  389. return items.sort((a, b) =>
  390. String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN', { numeric: true, sensitivity: 'base' })
  391. || (parseInt(a.path || '0', 10) || Number.MAX_SAFE_INTEGER) - (parseInt(b.path || '0', 10) || Number.MAX_SAFE_INTEGER)
  392. || (parseInt(a.id || a._id || '0', 10) || 0) - (parseInt(b.id || b._id || '0', 10) || 0)
  393. );
  394. }
  395. function getFilteredDocs() {
  396. const q = document.getElementById('searchInput').value.toLowerCase().trim();
  397. const filtered = !q ? allDocs : allDocs.filter(d =>
  398. (d.name || '').toLowerCase().includes(q) ||
  399. String(d.path || '').includes(q) ||
  400. String(d.id || d._id || '').includes(q) ||
  401. (d.tags || []).some(t => (t.name || String(t)).toLowerCase().includes(q))
  402. );
  403. return sortDocs(filtered);
  404. }
  405. function renderVisibleDocs() {
  406. renderDocList(getFilteredDocs());
  407. }
  408. function applyEmbedMode() {
  409. if (!embedMode) return;
  410. document.body.classList.add('embed');
  411. if (embedMode === 'landing') {
  412. document.body.classList.add('landing-embed');
  413. mode = 'view';
  414. }
  415. }
  416. // --- API helper ---
  417. async function dcApi(action, body = {}) {
  418. const res = await fetch(`/api/doccenter/${action}`, {
  419. method: 'POST',
  420. headers: { 'Content-Type': 'application/json' },
  421. body: JSON.stringify(body),
  422. });
  423. if (!res.ok) {
  424. const err = await res.json().catch(() => ({ error: res.statusText }));
  425. throw new Error(err.error || `HTTP ${res.status}`);
  426. }
  427. return res.json();
  428. }
  429. // --- Toast ---
  430. function toast(msg, type = '') {
  431. const el = document.getElementById('toast');
  432. el.textContent = msg;
  433. el.className = 'toast show ' + type;
  434. clearTimeout(el._timer);
  435. el._timer = setTimeout(() => el.className = 'toast', 3000);
  436. }
  437. // --- Document List ---
  438. async function refreshList() {
  439. const listEl = document.getElementById('docList');
  440. listEl.innerHTML = '<div class="list-status">Loading...</div>';
  441. setStatus('Fetching document list...');
  442. try {
  443. const res = await dcApi('list', { keyword: '', tagId: 0, page: 1, pageSize: 200 });
  444. const list = res?.data?.list || res?.list || (Array.isArray(res?.data) ? res.data : []);
  445. allDocs = list;
  446. renderVisibleDocs();
  447. if (pendingDocId) {
  448. const targetDocId = pendingDocId;
  449. pendingDocId = null;
  450. await selectDoc(targetDocId, { updateHistory: false });
  451. }
  452. setStatus(`${allDocs.length} documents loaded`);
  453. } catch (e) {
  454. listEl.innerHTML = `<div class="list-status" style="color:var(--red)">Error: ${esc(e.message)}</div>`;
  455. setStatus('Failed to load documents');
  456. toast(e.message, 'error');
  457. }
  458. }
  459. function renderDocList(docs) {
  460. const listEl = document.getElementById('docList');
  461. if (!docs.length) {
  462. listEl.innerHTML = '<div class="list-status">No documents found</div>';
  463. return;
  464. }
  465. listEl.innerHTML = docs.map(d => {
  466. const docId = parseInt(d.id ?? d._id, 10);
  467. const isActive = currentDoc && currentDoc.id === docId;
  468. const tags = (d.tags || []).map(t => `<span class="doc-tag">${esc(t.name || t)}</span>`).join('');
  469. const scope = docScope(d);
  470. const latestVersion = d.latestVersion || d.latest_version || d.version || 0;
  471. return `<div class="doc-item${isActive ? ' active' : ''}" data-id="${docId}" onclick="selectDoc(${docId})">
  472. <span class="doc-name">${esc(d.name || 'Untitled')}</span>
  473. <div class="doc-ref">${esc(formatDocRef(docId))}</div>
  474. <div class="doc-meta">
  475. <span class="doc-badge ${scope.className}">${scope.label}</span>
  476. <span class="doc-badge doc-badge-id">ID ${docId}</span>
  477. <span class="doc-badge doc-badge-path">Slot ${d.path ?? docId}</span>
  478. <span class="doc-badge doc-badge-version">v${latestVersion}</span>
  479. </div>
  480. <div class="doc-tags">${tags}${formatDate(d.updatedAt || d.createdAt) ? `<span class="doc-tag">${esc(formatDate(d.updatedAt || d.createdAt))}</span>` : ''}</div>
  481. </div>`;
  482. }).join('');
  483. }
  484. function filterDocs() {
  485. renderVisibleDocs();
  486. }
  487. // --- Select & Load Document ---
  488. async function selectDoc(docId, { updateHistory = true } = {}) {
  489. if (currentDoc && currentDoc.id === docId) return;
  490. if (currentDoc && isModified()) {
  491. if (!confirm('You have unsaved changes. Discard them?')) return;
  492. }
  493. setStatus(`Loading document #${docId}...`);
  494. try {
  495. const res = await dcApi('get', { docId });
  496. const data = res?.data || res;
  497. currentDoc = {
  498. id: data.id || docId,
  499. name: data.name || 'Untitled',
  500. path: data.path,
  501. content: data.currentContent || data.content || '',
  502. updatedAt: data.updatedAt,
  503. };
  504. originalContent = currentDoc.content;
  505. showDocument();
  506. if (updateHistory) updateDocLocation(currentDoc.id);
  507. updateButtons();
  508. renderDocList(allDocs); // refresh active highlight
  509. setStatus(`Loaded: ${currentDoc.name}`);
  510. } catch (e) {
  511. toast('Failed to load document: ' + e.message, 'error');
  512. setStatus('Error loading document');
  513. }
  514. }
  515. function showDocument() {
  516. const toolbar = document.getElementById('toolbar');
  517. toolbar.style.display = 'flex';
  518. document.getElementById('docIdLabel').textContent = `Doc ID ${currentDoc.id}`;
  519. document.getElementById('docRefLabel').textContent = formatDocRef(currentDoc.id);
  520. document.getElementById('docTitleLabel').textContent = currentDoc.name;
  521. document.getElementById('btnCopyDocId').disabled = false;
  522. document.getElementById('btnCopyDocLink').disabled = false;
  523. renderContent();
  524. }
  525. function renderContent() {
  526. const area = document.getElementById('contentArea');
  527. if (mode === 'edit') {
  528. area.innerHTML = `<textarea id="editor" spellcheck="false" oninput="onEditorInput()">${esc(currentDoc.content)}</textarea>`;
  529. document.getElementById('editor').focus();
  530. } else {
  531. area.innerHTML = `<div class="md-view">${renderMarkdown(currentDoc.content)}</div>`;
  532. }
  533. updateStatusChars();
  534. }
  535. function onEditorInput() {
  536. const editor = document.getElementById('editor');
  537. if (editor) currentDoc.content = editor.value;
  538. updateButtons();
  539. updateStatusChars();
  540. }
  541. function isModified() {
  542. return currentDoc && currentDoc.content !== originalContent;
  543. }
  544. function updateButtons() {
  545. const mod = isModified();
  546. document.getElementById('btnSave').disabled = !currentDoc || !mod;
  547. document.getElementById('btnPublish').disabled = !currentDoc;
  548. const modEl = document.getElementById('statusModified');
  549. modEl.textContent = mod ? 'Modified' : '';
  550. modEl.className = mod ? 'modified' : '';
  551. }
  552. function updateStatusChars() {
  553. const el = document.getElementById('statusChars');
  554. if (currentDoc) {
  555. const len = currentDoc.content.length;
  556. const lines = currentDoc.content.split('\n').length;
  557. el.textContent = `${lines} lines, ${len} chars`;
  558. } else {
  559. el.textContent = '';
  560. }
  561. }
  562. function updateDocLocation(docId) {
  563. if (!window.history?.replaceState) return;
  564. const url = new URL(window.location.href);
  565. if (embedMode) url.searchParams.set('embed', embedMode);
  566. else url.searchParams.delete('embed');
  567. url.searchParams.delete('t');
  568. if (docId) url.searchParams.set('docId', String(docId));
  569. else url.searchParams.delete('docId');
  570. url.searchParams.delete('ref');
  571. window.history.replaceState({}, '', `${url.pathname}?${url.searchParams.toString()}`);
  572. }
  573. function copyCurrentDocId() {
  574. if (!currentDoc?.id) return;
  575. navigator.clipboard.writeText(String(currentDoc.id))
  576. .then(() => toast('Doc ID copied', 'success'))
  577. .catch(() => toast('Failed to copy doc ID', 'error'));
  578. }
  579. function copyCurrentDocLink() {
  580. if (!currentDoc?.id) return;
  581. const value = formatDocHref(currentDoc.id, { absolute: true });
  582. navigator.clipboard.writeText(value)
  583. .then(() => toast('Doc link copied', 'success'))
  584. .catch(() => toast('Failed to copy doc link', 'error'));
  585. }
  586. // --- Mode switching ---
  587. function setMode(m) {
  588. if (embedMode === 'landing' && m !== 'view') return;
  589. mode = m;
  590. document.querySelectorAll('.mode-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === m));
  591. if (currentDoc) renderContent();
  592. }
  593. // --- Save ---
  594. async function saveDoc() {
  595. if (!currentDoc || !isModified()) return;
  596. setStatus('Saving...');
  597. document.getElementById('btnSave').disabled = true;
  598. try {
  599. await dcApi('save', { docId: currentDoc.id, content: currentDoc.content });
  600. originalContent = currentDoc.content;
  601. updateButtons();
  602. toast('Document saved', 'success');
  603. setStatus('Saved');
  604. } catch (e) {
  605. toast('Save failed: ' + e.message, 'error');
  606. setStatus('Save failed');
  607. document.getElementById('btnSave').disabled = false;
  608. }
  609. }
  610. // --- Publish ---
  611. function publishDoc() {
  612. if (!currentDoc) return;
  613. document.getElementById('publishMessage').value = '';
  614. document.getElementById('publishDialog').classList.add('open');
  615. }
  616. function closePublishDialog() {
  617. document.getElementById('publishDialog').classList.remove('open');
  618. }
  619. async function confirmPublish() {
  620. const msg = document.getElementById('publishMessage').value.trim() || 'Published from VL-Code';
  621. closePublishDialog();
  622. setStatus('Publishing...');
  623. try {
  624. // Save first if modified
  625. if (isModified()) {
  626. await dcApi('save', { docId: currentDoc.id, content: currentDoc.content });
  627. originalContent = currentDoc.content;
  628. }
  629. await dcApi('publish', {
  630. docId: currentDoc.id,
  631. path: currentDoc.path,
  632. currentContent: currentDoc.content,
  633. changeNote: msg,
  634. message: msg,
  635. });
  636. toast('Version published', 'success');
  637. setStatus('Published');
  638. updateButtons();
  639. } catch (e) {
  640. toast('Publish failed: ' + e.message, 'error');
  641. setStatus('Publish failed');
  642. }
  643. }
  644. // --- Create ---
  645. function showCreateDialog() {
  646. document.getElementById('newDocName').value = '';
  647. document.getElementById('newDocPath').value = '';
  648. document.getElementById('newDocContent').value = '';
  649. document.getElementById('createDialog').classList.add('open');
  650. }
  651. function closeCreateDialog() {
  652. document.getElementById('createDialog').classList.remove('open');
  653. }
  654. async function createDoc() {
  655. const name = document.getElementById('newDocName').value.trim();
  656. const pathVal = document.getElementById('newDocPath').value.trim();
  657. const content = document.getElementById('newDocContent').value;
  658. if (!name) { toast('Name is required', 'error'); return; }
  659. closeCreateDialog();
  660. setStatus('Creating document...');
  661. try {
  662. const body = { name, content: content || '' };
  663. if (pathVal) body.path = parseInt(pathVal, 10);
  664. const res = await dcApi('create', body);
  665. toast('Document created', 'success');
  666. await refreshList();
  667. // Auto-select the new doc
  668. const newId = res?.data?.id || res?.id;
  669. if (newId) selectDoc(newId);
  670. } catch (e) {
  671. toast('Create failed: ' + e.message, 'error');
  672. setStatus('Create failed');
  673. }
  674. }
  675. // --- Tags ---
  676. async function loadTags() {
  677. try {
  678. const res = await dcApi('tags', {});
  679. const tags = res?.data || res?.list || [];
  680. toast(`${tags.length} tags loaded. (Tag management coming soon)`, '');
  681. } catch (e) {
  682. toast('Failed to load tags: ' + e.message, 'error');
  683. }
  684. }
  685. // --- Status ---
  686. function setStatus(text) {
  687. document.getElementById('statusText').textContent = text;
  688. }
  689. // --- Markdown renderer (lightweight, no deps) ---
  690. function renderMarkdown(md) {
  691. if (!md) return '<p style="color:var(--text2)">Empty document</p>';
  692. let html = esc(md);
  693. // Code blocks (``` ... ```)
  694. html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
  695. `<pre><code>${code}</code></pre>`
  696. );
  697. // Inline code
  698. html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
  699. // Headers
  700. html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
  701. html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
  702. html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
  703. html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
  704. // Horizontal rules
  705. html = html.replace(/^---+$/gm, '<hr>');
  706. // Bold and italic
  707. html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
  708. html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
  709. // Links
  710. html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
  711. // Images
  712. html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
  713. // Blockquotes
  714. html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
  715. // Tables: detect lines with |
  716. html = html.replace(/((?:^\|.+\|$\n?)+)/gm, (block) => {
  717. const rows = block.trim().split('\n').filter(r => r.trim());
  718. if (rows.length < 2) return block;
  719. // Check if second row is separator
  720. const isSep = /^\|[\s\-:]+\|$/.test(rows[1]);
  721. let table = '<table>';
  722. rows.forEach((row, i) => {
  723. if (isSep && i === 1) return; // skip separator
  724. const cells = row.split('|').filter((_, ci, arr) => ci > 0 && ci < arr.length - 1);
  725. const tag = (isSep && i === 0) ? 'th' : 'td';
  726. table += '<tr>' + cells.map(c => `<${tag}>${c.trim()}</${tag}>`).join('') + '</tr>';
  727. });
  728. table += '</table>';
  729. return table;
  730. });
  731. // Unordered lists
  732. html = html.replace(/^[\-\*] (.+)$/gm, '<li>$1</li>');
  733. html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
  734. // Ordered lists
  735. html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
  736. // Paragraphs: wrap remaining text blocks
  737. html = html.replace(/^(?!<[a-z])([\s\S]+?)(?=\n\n|$)/gm, (match) => {
  738. const trimmed = match.trim();
  739. if (!trimmed || /^</.test(trimmed)) return match;
  740. return `<p>${trimmed}</p>`;
  741. });
  742. // Clean up double newlines
  743. html = html.replace(/\n{2,}/g, '\n');
  744. return html;
  745. }
  746. // --- Helpers ---
  747. function esc(s) {
  748. if (!s) return '';
  749. return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  750. }
  751. function formatDate(ts) {
  752. if (!ts) return '';
  753. try {
  754. const d = new Date(ts);
  755. if (isNaN(d)) return '';
  756. return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
  757. } catch { return ''; }
  758. }
  759. // --- Keyboard shortcuts ---
  760. document.addEventListener('keydown', (e) => {
  761. // Ctrl/Cmd+S = Save
  762. if ((e.ctrlKey || e.metaKey) && e.key === 's') {
  763. e.preventDefault();
  764. saveDoc();
  765. }
  766. // Escape closes dialogs
  767. if (e.key === 'Escape') {
  768. closeCreateDialog();
  769. closePublishDialog();
  770. }
  771. });
  772. // Close dialogs on overlay click
  773. document.querySelectorAll('.dialog-overlay').forEach(el => {
  774. el.addEventListener('click', (e) => {
  775. if (e.target === el) el.classList.remove('open');
  776. });
  777. });
  778. // --- Init ---
  779. applyEmbedMode();
  780. refreshList();
  781. </script>
  782. </body>
  783. </html>