doc-center.html 28 KB

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