|
| 1 | +import { app } from "../../scripts/app.js"; |
| 2 | + |
| 3 | +const LIST_URL = "/akurate/hash_vault/list"; |
| 4 | +const THUMB_URL = (hash, name) => |
| 5 | + `/view?filename=${encodeURIComponent(name)}&type=output&subfolder=hash_vault&rand=${hash.slice(0, 8)}`; |
| 6 | + |
| 7 | +const STYLE = ` |
| 8 | +.akurate-hv-backdrop { |
| 9 | + position: fixed; inset: 0; background: rgba(0,0,0,0.82); |
| 10 | + z-index: 10000; display: flex; align-items: center; justify-content: center; |
| 11 | + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; |
| 12 | +} |
| 13 | +.akurate-hv-modal { |
| 14 | + background: #1a1a1a; color: #eee; border: 1px solid #333; border-radius: 8px; |
| 15 | + width: 92vw; height: 90vh; display: flex; flex-direction: column; |
| 16 | + box-shadow: 0 20px 60px rgba(0,0,0,0.6); |
| 17 | +} |
| 18 | +.akurate-hv-header { |
| 19 | + padding: 14px 20px; border-bottom: 1px solid #333; |
| 20 | + display: flex; align-items: center; gap: 16px; |
| 21 | +} |
| 22 | +.akurate-hv-header h2 { margin: 0; font-size: 16px; font-weight: 500; } |
| 23 | +.akurate-hv-count { color: #888; font-size: 13px; } |
| 24 | +.akurate-hv-search { |
| 25 | + flex: 1; background: #0f0f0f; color: #eee; border: 1px solid #333; |
| 26 | + border-radius: 4px; padding: 6px 10px; font-size: 13px; |
| 27 | + font-family: inherit; outline: none; |
| 28 | +} |
| 29 | +.akurate-hv-search:focus { border-color: #4a90e2; } |
| 30 | +.akurate-hv-close { |
| 31 | + background: transparent; color: #aaa; border: 1px solid #333; |
| 32 | + border-radius: 4px; padding: 4px 10px; cursor: pointer; font-size: 14px; |
| 33 | +} |
| 34 | +.akurate-hv-close:hover { background: #333; color: #fff; } |
| 35 | +.akurate-hv-body { |
| 36 | + flex: 1; overflow-y: auto; padding: 16px 20px; |
| 37 | +} |
| 38 | +.akurate-hv-grid { |
| 39 | + display: grid; gap: 14px; |
| 40 | + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); |
| 41 | +} |
| 42 | +.akurate-hv-card { |
| 43 | + background: #111; border: 1px solid #2a2a2a; border-radius: 6px; |
| 44 | + overflow: hidden; cursor: pointer; transition: border-color 0.12s, transform 0.12s; |
| 45 | + display: flex; flex-direction: column; |
| 46 | +} |
| 47 | +.akurate-hv-card:hover { border-color: #4a90e2; transform: translateY(-1px); } |
| 48 | +.akurate-hv-thumb { |
| 49 | + width: 100%; aspect-ratio: 1 / 1; background: #0a0a0a; |
| 50 | + display: flex; align-items: center; justify-content: center; |
| 51 | + color: #555; font-size: 36px; |
| 52 | +} |
| 53 | +.akurate-hv-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } |
| 54 | +.akurate-hv-meta { |
| 55 | + padding: 8px 10px; font-size: 12px; line-height: 1.35; |
| 56 | + display: flex; flex-direction: column; gap: 2px; min-height: 54px; |
| 57 | +} |
| 58 | +.akurate-hv-label { color: #eee; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 59 | +.akurate-hv-label.empty { color: #666; font-style: italic; font-weight: 400; } |
| 60 | +.akurate-hv-sub { color: #888; font-size: 11px; } |
| 61 | +.akurate-hv-empty { |
| 62 | + text-align: center; color: #666; padding: 60px 20px; font-size: 14px; |
| 63 | +} |
| 64 | +.akurate-hv-toast { |
| 65 | + position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); |
| 66 | + background: #2a2a2a; color: #eee; border: 1px solid #4a90e2; |
| 67 | + padding: 10px 18px; border-radius: 6px; font-size: 13px; |
| 68 | + z-index: 10001; opacity: 0; transition: opacity 0.2s; |
| 69 | + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; |
| 70 | +} |
| 71 | +.akurate-hv-toast.show { opacity: 1; } |
| 72 | +`; |
| 73 | + |
| 74 | +function injectStyle() { |
| 75 | + if (document.getElementById("akurate-hv-style")) return; |
| 76 | + const el = document.createElement("style"); |
| 77 | + el.id = "akurate-hv-style"; |
| 78 | + el.textContent = STYLE; |
| 79 | + document.head.appendChild(el); |
| 80 | +} |
| 81 | + |
| 82 | +function formatBytes(n) { |
| 83 | + if (!n) return "—"; |
| 84 | + if (n < 1024) return `${n} B`; |
| 85 | + if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`; |
| 86 | + if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`; |
| 87 | + return `${(n / 1024 ** 3).toFixed(2)} GB`; |
| 88 | +} |
| 89 | + |
| 90 | +function relativeAge(isoString) { |
| 91 | + if (!isoString) return "unknown"; |
| 92 | + const then = new Date(isoString).getTime(); |
| 93 | + const now = Date.now(); |
| 94 | + const secs = Math.max(0, (now - then) / 1000); |
| 95 | + if (secs < 60) return "just now"; |
| 96 | + if (secs < 3600) return `${Math.round(secs / 60)}m ago`; |
| 97 | + if (secs < 86400) return `${Math.round(secs / 3600)}h ago`; |
| 98 | + if (secs < 86400 * 30) return `${Math.round(secs / 86400)}d ago`; |
| 99 | + if (secs < 86400 * 365) return `${Math.round(secs / (86400 * 30))}mo ago`; |
| 100 | + return `${Math.round(secs / (86400 * 365))}y ago`; |
| 101 | +} |
| 102 | + |
| 103 | +function showToast(text) { |
| 104 | + const toast = document.createElement("div"); |
| 105 | + toast.className = "akurate-hv-toast"; |
| 106 | + toast.textContent = text; |
| 107 | + document.body.appendChild(toast); |
| 108 | + requestAnimationFrame(() => toast.classList.add("show")); |
| 109 | + setTimeout(() => { |
| 110 | + toast.classList.remove("show"); |
| 111 | + setTimeout(() => toast.remove(), 250); |
| 112 | + }, 1600); |
| 113 | +} |
| 114 | + |
| 115 | +function buildCard(entry) { |
| 116 | + const card = document.createElement("div"); |
| 117 | + card.className = "akurate-hv-card"; |
| 118 | + |
| 119 | + const thumb = document.createElement("div"); |
| 120 | + thumb.className = "akurate-hv-thumb"; |
| 121 | + if (entry.thumbnail) { |
| 122 | + const img = document.createElement("img"); |
| 123 | + img.src = THUMB_URL(entry.hash_key, entry.thumbnail); |
| 124 | + img.loading = "lazy"; |
| 125 | + img.onerror = () => { |
| 126 | + thumb.innerHTML = ""; |
| 127 | + thumb.textContent = "⋄"; |
| 128 | + }; |
| 129 | + thumb.appendChild(img); |
| 130 | + } else { |
| 131 | + const kind = entry.payload?.kind || "?"; |
| 132 | + thumb.textContent = kind === "dict" ? "{…}" : kind === "tensor" ? "⊞" : "⋄"; |
| 133 | + } |
| 134 | + card.appendChild(thumb); |
| 135 | + |
| 136 | + const meta = document.createElement("div"); |
| 137 | + meta.className = "akurate-hv-meta"; |
| 138 | + |
| 139 | + const label = document.createElement("div"); |
| 140 | + label.className = "akurate-hv-label"; |
| 141 | + if (entry.label) { |
| 142 | + label.textContent = entry.label; |
| 143 | + } else { |
| 144 | + label.classList.add("empty"); |
| 145 | + label.textContent = entry.hash_key.slice(0, 12); |
| 146 | + } |
| 147 | + |
| 148 | + const sub = document.createElement("div"); |
| 149 | + sub.className = "akurate-hv-sub"; |
| 150 | + sub.textContent = `${relativeAge(entry.last_accessed_at)} · ${formatBytes(entry.pt_size)}`; |
| 151 | + |
| 152 | + meta.appendChild(label); |
| 153 | + meta.appendChild(sub); |
| 154 | + card.appendChild(meta); |
| 155 | + |
| 156 | + card.addEventListener("click", () => { |
| 157 | + navigator.clipboard.writeText(entry.hash_key).then( |
| 158 | + () => showToast(`Hash copied: ${entry.hash_key.slice(0, 12)}…`), |
| 159 | + () => showToast("Copy failed — selection blocked"), |
| 160 | + ); |
| 161 | + }); |
| 162 | + |
| 163 | + return card; |
| 164 | +} |
| 165 | + |
| 166 | +function buildModal(entries) { |
| 167 | + const backdrop = document.createElement("div"); |
| 168 | + backdrop.className = "akurate-hv-backdrop"; |
| 169 | + |
| 170 | + const modal = document.createElement("div"); |
| 171 | + modal.className = "akurate-hv-modal"; |
| 172 | + |
| 173 | + const header = document.createElement("div"); |
| 174 | + header.className = "akurate-hv-header"; |
| 175 | + const title = document.createElement("h2"); |
| 176 | + title.textContent = "Hash Vault Browser"; |
| 177 | + const count = document.createElement("span"); |
| 178 | + count.className = "akurate-hv-count"; |
| 179 | + const search = document.createElement("input"); |
| 180 | + search.className = "akurate-hv-search"; |
| 181 | + search.placeholder = "Filter by label…"; |
| 182 | + search.type = "text"; |
| 183 | + const close = document.createElement("button"); |
| 184 | + close.className = "akurate-hv-close"; |
| 185 | + close.textContent = "Close"; |
| 186 | + header.append(title, count, search, close); |
| 187 | + |
| 188 | + const body = document.createElement("div"); |
| 189 | + body.className = "akurate-hv-body"; |
| 190 | + const grid = document.createElement("div"); |
| 191 | + grid.className = "akurate-hv-grid"; |
| 192 | + body.appendChild(grid); |
| 193 | + |
| 194 | + modal.append(header, body); |
| 195 | + backdrop.appendChild(modal); |
| 196 | + |
| 197 | + function render(filter) { |
| 198 | + const needle = (filter || "").trim().toLowerCase(); |
| 199 | + grid.innerHTML = ""; |
| 200 | + let shown = 0; |
| 201 | + for (const entry of entries) { |
| 202 | + if (needle) { |
| 203 | + const hay = (entry.label || "").toLowerCase() + " " + entry.hash_key.toLowerCase(); |
| 204 | + if (!hay.includes(needle)) continue; |
| 205 | + } |
| 206 | + grid.appendChild(buildCard(entry)); |
| 207 | + shown++; |
| 208 | + } |
| 209 | + if (shown === 0) { |
| 210 | + const empty = document.createElement("div"); |
| 211 | + empty.className = "akurate-hv-empty"; |
| 212 | + empty.textContent = entries.length === 0 |
| 213 | + ? "Vault is empty — save something via Hash Vault (Save API Result) first." |
| 214 | + : "No entries match the filter."; |
| 215 | + grid.appendChild(empty); |
| 216 | + } |
| 217 | + count.textContent = needle ? `${shown} of ${entries.length}` : `${entries.length} entries`; |
| 218 | + } |
| 219 | + |
| 220 | + function dismiss() { |
| 221 | + backdrop.remove(); |
| 222 | + document.removeEventListener("keydown", onKey); |
| 223 | + } |
| 224 | + function onKey(e) { |
| 225 | + if (e.key === "Escape") dismiss(); |
| 226 | + } |
| 227 | + |
| 228 | + close.addEventListener("click", dismiss); |
| 229 | + backdrop.addEventListener("click", (e) => { if (e.target === backdrop) dismiss(); }); |
| 230 | + document.addEventListener("keydown", onKey); |
| 231 | + search.addEventListener("input", () => render(search.value)); |
| 232 | + |
| 233 | + document.body.appendChild(backdrop); |
| 234 | + search.focus(); |
| 235 | + render(""); |
| 236 | +} |
| 237 | + |
| 238 | +async function openBrowser() { |
| 239 | + injectStyle(); |
| 240 | + try { |
| 241 | + const resp = await fetch(LIST_URL); |
| 242 | + if (!resp.ok) { |
| 243 | + showToast(`Vault list failed: HTTP ${resp.status}`); |
| 244 | + return; |
| 245 | + } |
| 246 | + const data = await resp.json(); |
| 247 | + buildModal(data.entries || []); |
| 248 | + } catch (e) { |
| 249 | + showToast(`Vault list failed: ${e.message}`); |
| 250 | + } |
| 251 | +} |
| 252 | + |
| 253 | +app.registerExtension({ |
| 254 | + name: "akurate.HashVaultBrowser", |
| 255 | + commands: [ |
| 256 | + { |
| 257 | + id: "akurate.HashVaultBrowser.open", |
| 258 | + label: "AKURATE: Hash Vault Browser", |
| 259 | + icon: "pi pi-database", |
| 260 | + function: openBrowser, |
| 261 | + }, |
| 262 | + ], |
| 263 | + menuCommands: [ |
| 264 | + { |
| 265 | + path: ["Extensions"], |
| 266 | + commands: ["akurate.HashVaultBrowser.open"], |
| 267 | + }, |
| 268 | + ], |
| 269 | +}); |
0 commit comments