Knot server viewer. knotview.srv.rbrt.fr
tangled knot
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: allow browsing repo by handle instead of listing knot repo

+397 -283
+115 -35
api.js
··· 1 1 const API = (() => { 2 - const ENDPOINTS = { 2 + const KNOT_ENDPOINTS = { 3 3 owner: "sh.tangled.owner", 4 4 tree: "sh.tangled.repo.tree", 5 5 blob: "sh.tangled.repo.blob", ··· 9 9 archive: "sh.tangled.repo.archive", 10 10 }; 11 11 12 - let baseUrl = ""; 12 + let knotBaseUrl = ""; 13 13 14 - function setBaseUrl(url) { 15 - baseUrl = url.replace(/\/+$/, ""); 14 + function setKnotBaseUrl(url) { 15 + knotBaseUrl = url.replace(/\/+$/, ""); 16 16 } 17 17 18 - function getBaseUrl() { 19 - return baseUrl; 18 + function getKnotBaseUrl() { 19 + return knotBaseUrl; 20 20 } 21 21 22 22 async function fetchWithRetry(url, options = {}, retries = 3) { ··· 39 39 throw lastError; 40 40 } 41 41 42 + async function resolveHandle(input) { 43 + if (input.startsWith("did:")) { 44 + return input; 45 + } 46 + 47 + const handle = input.replace(/^@/, ""); 48 + 49 + try { 50 + const response = await fetch( 51 + `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 52 + ); 53 + if (response.ok) { 54 + const data = await response.json(); 55 + return data.did; 56 + } 57 + } catch {} 58 + 59 + const response = await fetch( 60 + `https://plc.directory/${encodeURIComponent(handle)}`, 61 + ); 62 + if (response.ok) { 63 + return handle; 64 + } 65 + 66 + throw new Error(`Could not resolve handle: ${handle}`); 67 + } 68 + 69 + async function resolveDidDocument(did) { 70 + let url; 71 + if (did.startsWith("did:plc:")) { 72 + url = `https://plc.directory/${encodeURIComponent(did)}`; 73 + } else if (did.startsWith("did:web:")) { 74 + const domain = did.slice("did:web:".length); 75 + url = `https://${domain}/.well-known/did.json`; 76 + } else { 77 + throw new Error(`Unsupported DID method: ${did}`); 78 + } 79 + 80 + const response = await fetch(url); 81 + if (!response.ok) throw new Error(`Failed to resolve DID: ${response.status}`); 82 + return response.json(); 83 + } 84 + 85 + function getPdsEndpoint(didDocument) { 86 + if (didDocument.service) { 87 + const atprotoPds = didDocument.service.find( 88 + (s) => s.type === "AtprotoPersonalDataServer" || s.id === "#atproto_pds", 89 + ); 90 + if (atprotoPds) { 91 + return atprotoPds.serviceEndpoint.replace(/\/+$/, ""); 92 + } 93 + } 94 + return null; 95 + } 96 + 97 + function getHandle(didDocument) { 98 + if (didDocument.alsoKnownAs && didDocument.alsoKnownAs.length > 0) { 99 + const handle = didDocument.alsoKnownAs[0].replace(/^at:\/\//, ""); 100 + if (handle && !handle.startsWith("did:")) return handle; 101 + } 102 + return null; 103 + } 104 + 105 + async function listRepos(did, pdsEndpoint) { 106 + const pds = pdsEndpoint || "https://bsky.social"; 107 + const records = []; 108 + let cursor = undefined; 109 + 110 + do { 111 + let url = `${pds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=sh.tangled.repo&limit=100`; 112 + if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`; 113 + 114 + const response = await fetch(url); 115 + if (!response.ok) throw new Error(`Failed to list repos: ${response.status}`); 116 + const data = await response.json(); 117 + 118 + if (data.records) { 119 + records.push(...data.records); 120 + } 121 + cursor = data.cursor || undefined; 122 + } while (cursor); 123 + 124 + return records.map((r) => ({ 125 + name: r.value.name, 126 + description: r.value.description || null, 127 + knot: r.value.knot, 128 + ownerDid: did, 129 + repoDid: r.value.repoDid || null, 130 + rkey: r.uri.split("/").pop(), 131 + uri: r.uri, 132 + createdAt: r.value.createdAt, 133 + website: r.value.website || null, 134 + topics: r.value.topics || [], 135 + })); 136 + } 137 + 42 138 async function getOwner() { 43 - const url = `${baseUrl}/xrpc/${ENDPOINTS.owner}`; 139 + const url = `${knotBaseUrl}/xrpc/${KNOT_ENDPOINTS.owner}`; 44 140 const response = await fetchWithRetry(url); 45 141 return response.json(); 46 142 } 47 143 48 144 async function getDefaultBranch(repo) { 49 - const url = `${baseUrl}/xrpc/${ENDPOINTS.defaultBranch}?repo=${encodeURIComponent(repo)}`; 145 + const url = `${knotBaseUrl}/xrpc/${KNOT_ENDPOINTS.defaultBranch}?repo=${encodeURIComponent(repo)}`; 50 146 const response = await fetch(url); 51 147 if (!response.ok) throw new Error(`HTTP ${response.status}`); 52 148 const data = await response.json(); ··· 54 150 } 55 151 56 152 async function getBranches(repo) { 57 - const url = `${baseUrl}/xrpc/${ENDPOINTS.branches}?repo=${encodeURIComponent(repo)}`; 153 + const url = `${knotBaseUrl}/xrpc/${KNOT_ENDPOINTS.branches}?repo=${encodeURIComponent(repo)}`; 58 154 const response = await fetch(url); 59 155 if (!response.ok) throw new Error(`HTTP ${response.status}`); 60 156 return response.json(); 61 157 } 62 158 63 159 async function getTree(repo, ref, path = "") { 64 - const url = `${baseUrl}/xrpc/${ENDPOINTS.tree}?repo=${encodeURIComponent(repo)}&ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(path)}`; 160 + const url = `${knotBaseUrl}/xrpc/${KNOT_ENDPOINTS.tree}?repo=${encodeURIComponent(repo)}&ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(path)}`; 65 161 const response = await fetchWithRetry(url); 66 162 return response.json(); 67 163 } 68 164 69 165 async function getBlob(repo, ref, path) { 70 - const url = `${baseUrl}/xrpc/${ENDPOINTS.blob}?repo=${encodeURIComponent(repo)}&ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(path)}`; 166 + const url = `${knotBaseUrl}/xrpc/${KNOT_ENDPOINTS.blob}?repo=${encodeURIComponent(repo)}&ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(path)}`; 71 167 const response = await fetchWithRetry(url); 72 168 const data = await response.json(); 73 169 if (data.isBinary && data.encoding === "base64" && data.content) { ··· 77 173 } 78 174 79 175 function getArchiveUrl(repo, ref) { 80 - return `${baseUrl}/xrpc/${ENDPOINTS.archive}?repo=${encodeURIComponent(repo)}&ref=${encodeURIComponent(ref)}`; 81 - } 82 - 83 - async function resolveDID(did) { 84 - try { 85 - const url = `https://plc.directory/${encodeURIComponent(did)}`; 86 - const response = await fetch(url); 87 - if (!response.ok) return null; 88 - 89 - const didDocument = await response.json(); 90 - 91 - if (didDocument.alsoKnownAs && didDocument.alsoKnownAs.length > 0) { 92 - const handle = didDocument.alsoKnownAs[0].replace(/^at:\/\//, ""); 93 - return handle; 94 - } 95 - 96 - return null; 97 - } catch (error) { 98 - console.error("Failed to resolve DID:", error); 99 - return null; 100 - } 176 + return `${knotBaseUrl}/xrpc/${KNOT_ENDPOINTS.archive}?repo=${encodeURIComponent(repo)}&ref=${encodeURIComponent(ref)}`; 101 177 } 102 178 103 179 return { 104 - setBaseUrl, 105 - getBaseUrl, 180 + setKnotBaseUrl, 181 + getKnotBaseUrl, 182 + resolveHandle, 183 + resolveDidDocument, 184 + getPdsEndpoint, 185 + getHandle, 186 + listRepos, 106 187 getOwner, 107 188 getDefaultBranch, 108 189 getBranches, 109 190 getTree, 110 191 getBlob, 111 192 getArchiveUrl, 112 - resolveDID, 113 193 }; 114 194 })();
+94 -135
app.js
··· 1 - // Alpine.js App Component 2 1 document.addEventListener("alpine:init", () => { 3 2 Alpine.data("app", () => ({ 4 - // State 5 3 darkTheme: localStorage.getItem("darkTheme") !== "false", 6 - serverUrl: "https://knot.srv.rbrt.fr", 7 - isConnected: false, 4 + handleInput: "", 5 + resolvedDid: null, 6 + resolvedHandle: null, 7 + repos: [], 8 8 status: { 9 9 message: "", 10 10 type: "", ··· 13 13 currentRepo: null, 14 14 currentBranch: "main", 15 15 currentPath: "", 16 - resolvedHandle: null, 17 16 }, 18 17 branches: [], 19 - view: "empty", // empty, tree, file 18 + view: "empty", 20 19 loading: false, 21 20 loadingMessage: "", 22 21 error: null, ··· 29 28 isBinary: false, 30 29 isMarkdown: false, 31 30 }, 32 - manualRepoPath: "", 33 31 isRestoringFromURL: false, 34 32 35 - // Initialization 36 33 init() { 37 - // Apply saved theme 38 34 this.applyTheme(); 39 - 40 - // Make this component globally accessible for onclick handlers 41 35 window.appInstance = this; 42 36 43 - // Configure marked for syntax highlighting 44 37 if (typeof marked !== "undefined" && typeof hljs !== "undefined") { 45 38 marked.setOptions({ 46 39 highlight: (code, lang) => { ··· 56 49 }); 57 50 } 58 51 59 - // Handle browser back/forward 60 52 window.addEventListener("popstate", async (event) => { 61 53 if (event.state) { 62 54 this.isRestoringFromURL = true; ··· 66 58 } 67 59 }); 68 60 69 - // Restore from URL on load 70 61 this.restoreFromURL(); 71 62 }, 72 63 73 - // Theme 74 64 toggleTheme() { 75 65 this.darkTheme = !this.darkTheme; 76 66 localStorage.setItem("darkTheme", this.darkTheme); ··· 85 75 } 86 76 }, 87 77 88 - // Connection 89 - async connectToServer() { 90 - let url = this.serverUrl.trim(); 91 - if (!url) { 92 - this.showStatus("Please enter a server URL", "error"); 78 + async loadHandle() { 79 + const input = this.handleInput.trim(); 80 + if (!input) { 81 + this.showStatus("Please enter a handle or DID", "error"); 93 82 return; 94 83 } 95 84 96 - if (!url.startsWith("http://") && !url.startsWith("https://")) { 97 - url = "https://" + url; 98 - this.serverUrl = url; 99 - } 85 + try { 86 + this.showLoading("Resolving identity..."); 100 87 101 - try { 102 - this.showStatus("Connecting to server...", "success"); 103 - API.setBaseUrl(url); 88 + const did = await API.resolveHandle(input); 89 + this.resolvedDid = did; 104 90 105 - const data = await API.getOwner(); 106 - this.showStatus(`Connected to ${data.owner}`, "success"); 107 - this.isConnected = true; 91 + const didDoc = await API.resolveDidDocument(did); 92 + this.resolvedHandle = API.getHandle(didDoc); 108 93 109 - this.updateURL(); 110 - } catch (error) { 111 - this.showStatus(`Connection failed: ${error.message}`, "error"); 112 - } 113 - }, 94 + const pdsEndpoint = API.getPdsEndpoint(didDoc); 114 95 115 - loadManualRepo() { 116 - const repoPath = this.manualRepoPath.trim(); 117 - if (!repoPath) { 118 - this.showStatus("Please enter a repository path", "error"); 119 - return; 120 - } 96 + this.showLoading("Loading repositories..."); 97 + this.repos = await API.listRepos(did, pdsEndpoint); 121 98 122 - const parts = repoPath.split("/"); 123 - if (parts.length < 2) { 99 + this.view = "repoList"; 100 + this.loading = false; 124 101 this.showStatus( 125 - "Invalid repository path format. Expected: did:plc:xxx.../repo-name", 126 - "error", 102 + `Found ${this.repos.length} repos for ${this.resolvedHandle || did}`, 103 + "success", 127 104 ); 128 - return; 105 + this.updateURL(); 106 + } catch (error) { 107 + this.loading = false; 108 + this.showStatus(`Error: ${error.message}`, "error"); 129 109 } 130 - 131 - const repo = { 132 - fullPath: repoPath, 133 - did: parts[0], 134 - name: parts.slice(1).join("/"), 135 - }; 136 - 137 - this.selectRepository(repo); 138 110 }, 139 111 140 - // Repository Selection 141 112 async selectRepository(repo) { 142 113 try { 143 114 this.showLoading("Loading repository..."); 144 115 this.state.currentRepo = { 145 - fullPath: repo.fullPath, 146 - did: repo.did, 147 116 name: repo.name, 117 + knot: repo.knot, 118 + ownerDid: repo.ownerDid, 119 + repoDid: repo.repoDid, 120 + description: repo.description, 121 + website: repo.website, 122 + topics: repo.topics, 123 + uri: repo.uri, 148 124 }; 149 125 this.state.currentPath = ""; 150 126 this.view = "tree"; 151 127 152 - // Resolve handle 153 - const handle = await API.resolveDID(repo.did); 154 - if (handle) { 155 - this.state.resolvedHandle = handle; 156 - } 128 + const knotUrl = `https://${repo.knot}`; 129 + API.setKnotBaseUrl(knotUrl); 157 130 158 - // Get default branch 159 131 try { 160 - const branchData = await API.getDefaultBranch(repo.fullPath); 132 + const branchData = await API.getDefaultBranch( 133 + repo.repoDid || `${repo.ownerDid}/${repo.name}`, 134 + ); 161 135 this.state.currentBranch = branchData.branch || "main"; 162 - } catch (error) { 136 + } catch { 163 137 this.state.currentBranch = "main"; 164 138 } 165 139 ··· 174 148 175 149 async loadBranches() { 176 150 try { 177 - const data = await API.getBranches(this.state.currentRepo.fullPath); 151 + const repo = this.state.currentRepo; 152 + const data = await API.getBranches( 153 + repo.repoDid || `${repo.ownerDid}/${repo.name}`, 154 + ); 178 155 this.branches = data.branches || []; 179 156 } catch (error) { 180 157 console.error("Failed to load branches:", error); ··· 189 166 this.updateURL(); 190 167 }, 191 168 192 - // Tree/File Loading 169 + getRepoParam() { 170 + const repo = this.state.currentRepo; 171 + if (!repo) return ""; 172 + return repo.repoDid || `${repo.ownerDid}/${repo.name}`; 173 + }, 174 + 193 175 async loadTree(path = "") { 194 176 try { 195 177 this.showLoading("Loading..."); ··· 197 179 this.view = "tree"; 198 180 199 181 const data = await API.getTree( 200 - this.state.currentRepo.fullPath, 182 + this.getRepoParam(), 201 183 this.state.currentBranch, 202 184 path, 203 185 ); ··· 217 199 this.view = "file"; 218 200 219 201 const data = await API.getBlob( 220 - this.state.currentRepo.fullPath, 202 + this.getRepoParam(), 221 203 this.state.currentBranch, 222 204 path, 223 205 ); ··· 230 212 } 231 213 }, 232 214 233 - // Rendering 234 215 renderTree(data, path) { 235 216 this.breadcrumbHtml = this.renderBreadcrumb(path); 236 217 237 218 const files = data.files || []; 238 219 let html = ""; 239 220 240 - // Parent directory link 241 221 if (path) { 242 222 const parentPath = path.split("/").slice(0, -1).join("/"); 243 223 html += ` ··· 250 230 `; 251 231 } 252 232 253 - // Sort: directories first, then files 254 233 const dirs = files.filter((f) => { 234 + if (f.is_file === false) return true; 235 + if (f.is_file === true) return false; 255 236 const mode = f.mode || ""; 256 - return mode.startsWith("040"); 237 + return mode.startsWith("004") || mode === "040000" || f.type === "tree"; 257 238 }); 258 239 const regularFiles = files.filter((f) => { 240 + if (f.is_file === true) return true; 241 + if (f.is_file === false) return false; 259 242 const mode = f.mode || ""; 260 - return !mode.startsWith("040"); 243 + return !mode.startsWith("004") && mode !== "040000" && f.type !== "tree"; 261 244 }); 262 245 263 - // Render directories 264 246 dirs.forEach((file) => { 265 247 const fullPath = path ? `${path}/${file.name}` : file.name; 266 248 html += ` ··· 273 255 `; 274 256 }); 275 257 276 - // Render files 277 258 regularFiles.forEach((file) => { 278 259 const fullPath = path ? `${path}/${file.name}` : file.name; 279 260 const size = file.size ? this.formatSize(file.size) : ""; ··· 290 271 291 272 this.fileListHtml = html; 292 273 293 - // Handle README from API response 294 274 if (data.readme && data.readme.contents) { 295 275 const readmeHtml = 296 276 typeof marked !== "undefined" ··· 327 307 if (isMarkdown) { 328 308 this.currentFile.content = marked.parse(content); 329 309 } else { 330 - // Code with syntax highlighting 331 310 const lines = content.split("\n"); 332 311 const lineNumbers = lines.map((_, i) => i + 1).join("\n"); 333 312 ··· 403 382 return html; 404 383 }, 405 384 406 - // Actions 407 - showConnectView() { 385 + showRepoList() { 408 386 this.state.currentRepo = null; 409 387 this.state.currentBranch = "main"; 410 388 this.state.currentPath = ""; 411 - this.state.resolvedHandle = null; 412 389 this.branches = []; 413 - this.view = "empty"; 390 + this.view = "repoList"; 414 391 this.updateURL(); 415 392 }, 416 393 417 394 downloadFile() { 418 - const url = `${API.getBaseUrl()}/xrpc/sh.tangled.repo.blob?repo=${encodeURIComponent(this.state.currentRepo.fullPath)}&ref=${encodeURIComponent(this.state.currentBranch)}&path=${encodeURIComponent(this.state.currentPath)}&raw=true`; 395 + const url = `${API.getKnotBaseUrl()}/xrpc/sh.tangled.repo.blob?repo=${encodeURIComponent(this.getRepoParam())}&ref=${encodeURIComponent(this.state.currentBranch)}&path=${encodeURIComponent(this.state.currentPath)}&raw=true`; 419 396 window.location.href = url; 420 397 }, 421 398 ··· 431 408 }); 432 409 }, 433 410 434 - // URL Management 435 411 updateURL(replace = false) { 436 412 if (this.isRestoringFromURL) { 437 413 return; ··· 439 415 440 416 const params = []; 441 417 442 - if (API.getBaseUrl()) { 443 - params.push(`server=${encodeURIComponent(API.getBaseUrl())}`); 418 + if (this.resolvedDid) { 419 + params.push(`did=${encodeURIComponent(this.resolvedDid)}`); 444 420 } 445 421 446 422 if (this.state.currentRepo) { 447 - params.push( 448 - `repo=${this.state.currentRepo.fullPath.split("/").map(encodeURIComponent).join("/")}`, 449 - ); 423 + params.push(`repo=${encodeURIComponent(this.state.currentRepo.name)}`); 450 424 } 451 425 452 426 if (this.state.currentBranch && this.state.currentBranch !== "main") { ··· 462 436 const newURL = 463 437 params.length > 0 ? `?${params.join("&")}` : window.location.pathname; 464 438 465 - // Create a simple state object that can be cloned 466 439 const historyState = { 467 440 currentRepo: this.state.currentRepo 468 - ? { 469 - fullPath: this.state.currentRepo.fullPath, 470 - did: this.state.currentRepo.did, 471 - name: this.state.currentRepo.name, 472 - } 441 + ? JSON.parse(JSON.stringify(this.state.currentRepo)) 473 442 : null, 474 443 currentBranch: this.state.currentBranch, 475 444 currentPath: this.state.currentPath, 476 - resolvedHandle: this.state.resolvedHandle, 477 445 }; 478 446 479 447 if (replace) { ··· 485 453 486 454 async restoreFromURL() { 487 455 const params = new URLSearchParams(window.location.search); 488 - const server = params.get("server"); 489 - const repo = params.get("repo"); 456 + const did = params.get("did"); 457 + const repoName = params.get("repo"); 490 458 const branch = params.get("branch") || "main"; 491 459 const path = params.get("path") || ""; 492 460 493 - if (!server) { 461 + if (!did) { 494 462 return; 495 463 } 496 464 497 465 try { 498 466 this.isRestoringFromURL = true; 499 467 500 - this.serverUrl = server; 501 - API.setBaseUrl(server); 502 - 503 - const data = await API.getOwner(); 504 - this.showStatus(`Connected to ${data.owner}`, "success"); 505 - this.isConnected = true; 468 + this.resolvedDid = did; 469 + const didDoc = await API.resolveDidDocument(did); 470 + this.resolvedHandle = API.getHandle(didDoc); 506 471 507 - if (repo) { 508 - const parts = repo.split("/"); 509 - this.state.currentRepo = { 510 - fullPath: repo, 511 - did: parts[0], 512 - name: parts.slice(1).join("/"), 513 - }; 514 - this.state.currentBranch = branch; 515 - this.state.currentPath = path; 516 - 517 - const handle = await API.resolveDID(this.state.currentRepo.did); 518 - if (handle) { 519 - this.state.resolvedHandle = handle; 520 - } 521 - 522 - await this.loadBranches(); 523 - 524 - if (path) { 525 - try { 526 - await this.loadFile(path); 527 - } catch (error) { 528 - await this.loadTree(path); 529 - } 472 + if (repoName) { 473 + const pdsEndpoint = API.getPdsEndpoint(didDoc); 474 + const allRepos = await API.listRepos(did, pdsEndpoint); 475 + const repo = allRepos.find((r) => r.name === repoName); 476 + if (repo) { 477 + this.repos = allRepos; 478 + this.state.currentBranch = branch; 479 + this.state.currentPath = path; 480 + await this.selectRepository(repo); 530 481 } else { 531 - await this.loadTree(); 482 + throw new Error(`Repository "${repoName}" not found`); 532 483 } 533 - 534 - this.updateURL(true); 484 + } else { 485 + const pdsEndpoint = API.getPdsEndpoint(didDoc); 486 + this.repos = await API.listRepos(did, pdsEndpoint); 487 + this.view = "repoList"; 488 + this.showStatus( 489 + `Found ${this.repos.length} repos for ${this.resolvedHandle || did}`, 490 + "success", 491 + ); 535 492 } 536 493 537 494 this.isRestoringFromURL = false; 538 495 } catch (error) { 539 496 this.isRestoringFromURL = false; 540 497 this.showStatus( 541 - `Failed to restore from URL: ${error.message}`, 498 + `Failed to restore: ${error.message}`, 542 499 "error", 543 500 ); 544 501 } ··· 546 503 547 504 async restoreViewFromState() { 548 505 if (!this.state.currentRepo) { 549 - this.view = "empty"; 506 + this.view = this.repos.length > 0 ? "repoList" : "empty"; 550 507 return; 551 508 } 509 + 510 + const knotUrl = `https://${this.state.currentRepo.knot}`; 511 + API.setKnotBaseUrl(knotUrl); 552 512 553 513 await this.loadBranches(); 554 514 555 515 if (this.state.currentPath) { 556 516 try { 557 517 await this.loadFile(this.state.currentPath); 558 - } catch (error) { 518 + } catch { 559 519 await this.loadTree(this.state.currentPath); 560 520 } 561 521 } else { ··· 563 523 } 564 524 }, 565 525 566 - // UI Helpers 567 526 showStatus(message, type) { 568 527 this.status = { message, type }; 569 528 setTimeout(() => {
+82 -71
index.html
··· 23 23 <div class="container" x-data="app" x-init="init()"> 24 24 <header> 25 25 <div class="header-top"> 26 - <h1>KnotView</h1> 26 + <h1><a href="/" style="text-decoration:none;color:inherit">KnotView</a></h1> 27 27 <button class="theme-toggle" @click="toggleTheme"> 28 28 <span x-show="!darkTheme">🌙</span> 29 29 <span x-show="darkTheme">☀️</span> ··· 33 33 <div class="connection-panel"> 34 34 <input 35 35 type="text" 36 - x-model="serverUrl" 37 - placeholder="https://knot.example.com" 38 - @keyup.enter="connectToServer" 36 + x-model="handleInput" 37 + placeholder="handle.bsky.social or did:plc:..." 38 + @keyup.enter="loadHandle" 39 39 /> 40 - <button 41 - @click="connectToServer" 42 - :disabled="isConnected" 43 - x-text="isConnected ? 'Connected ✓' : 'Connect'" 44 - ></button> 40 + <button @click="loadHandle">Load</button> 45 41 </div> 46 42 <div 47 43 x-show="status.message" ··· 58 54 <h2> 59 55 Repository 60 56 <button 61 - @click="showConnectView" 57 + @click="showRepoList" 62 58 class="secondary" 63 59 style="padding: 6px 12px; font-size: 12px" 64 60 > ··· 77 73 <div class="label">Owner</div> 78 74 <div 79 75 class="value" 80 - x-text="state.resolvedHandle || state.currentRepo?.did" 76 + x-text="resolvedHandle || resolvedDid" 77 + ></div> 78 + 79 + <div class="label">Knot</div> 80 + <div 81 + class="value" 82 + x-text="state.currentRepo?.knot" 81 83 ></div> 82 84 85 + <template x-if="state.currentRepo?.description"> 86 + <div> 87 + <div class="label">Description</div> 88 + <div 89 + class="value" 90 + x-text="state.currentRepo?.description" 91 + ></div> 92 + </div> 93 + </template> 94 + 83 95 <div class="label">Clone URL</div> 84 96 <div class="clone-url"> 85 97 <code 86 - x-text="`${API.getBaseUrl()}/repo/${state.currentRepo?.fullPath}`" 98 + x-text="`git@${state.currentRepo?.knot}:${state.currentRepo?.ownerDid}/${state.currentRepo?.name}`" 87 99 ></code> 88 100 <button 89 101 class="copy-btn" 90 - @click="copyToClipboard(`${API.getBaseUrl()}/repo/${state.currentRepo?.fullPath}`)" 102 + @click="copyToClipboard(`git@${state.currentRepo?.knot}:${state.currentRepo?.ownerDid}/${state.currentRepo?.name}`)" 91 103 > 92 104 Copy 93 105 </button> 94 106 </div> 95 107 96 108 <button 97 - @click="window.location.href = `${API.getBaseUrl()}/xrpc/sh.tangled.repo.archive?repo=${encodeURIComponent(state.currentRepo?.fullPath)}&ref=${encodeURIComponent(state.currentBranch)}`" 109 + @click="window.location.href = `${API.getKnotBaseUrl()}/xrpc/sh.tangled.repo.archive?repo=${encodeURIComponent(state.currentRepo?.repoDid || state.currentRepo?.ownerDid + '/' + state.currentRepo?.name)}&ref=${encodeURIComponent(state.currentBranch)}`" 98 110 style="width: 100%; margin-top: 8px" 99 111 > 100 112 Download Archive ··· 133 145 x-html="error" 134 146 ></div> 135 147 136 - <!-- Empty State --> 148 + <!-- Welcome State --> 137 149 <div 138 - x-show="!loading && !error && !state.currentRepo && view === 'empty'" 150 + x-show="!loading && !error && view === 'empty'" 139 151 class="welcome-hero" 140 152 > 141 153 <svg ··· 154 166 <h2>Welcome to KnotView</h2> 155 167 <p class="subtitle"> 156 168 A web-based repository browser for Tangled Knots. 157 - Browse, explore, and download content from Knot 158 - servers. 169 + Enter an ATProto handle above to browse their 170 + repositories. 159 171 </p> 160 172 161 173 <div class="feature-list"> 162 174 <div class="feature-item"> 175 + <h4>🔍 Handle Lookup</h4> 176 + <p> 177 + Enter any Tangled handle to discover their 178 + repositories 179 + </p> 180 + </div> 181 + <div class="feature-item"> 163 182 <h4>🗂️ Browse Repositories</h4> 164 183 <p> 165 184 Navigate through files and folders with an ··· 179 198 markdown rendering 180 199 </p> 181 200 </div> 182 - <div class="feature-item"> 183 - <h4>📦 Download Archives</h4> 184 - <p>Download entire repositories as archives</p> 185 - </div> 186 201 </div> 187 202 188 203 <div class="getting-started"> 189 204 <h3>Getting Started</h3> 190 205 <ol> 191 206 <li> 192 - Enter your Knot server URL in the input 193 - above 207 + Enter a Tangled handle (e.g. julien.rbrt.fr) 208 + or DID in the search bar above 194 209 </li> 195 210 <li> 196 - Click "Connect" to connect to the server 211 + Select a repository from the list 197 212 </li> 198 - <li>Enter a repository path (did:plc:xxx/repo-name)</li> 199 213 <li> 200 214 Browse files, switch branches, and explore! 201 215 </li> ··· 203 217 </div> 204 218 </div> 205 219 206 - <!-- Manual Repo Entry --> 220 + <!-- Repo List --> 207 221 <div 208 - x-show="!loading && !error && isConnected && !state.currentRepo" 209 - class="empty-state" 222 + x-show="!loading && !error && view === 'repoList'" 223 + class="repo-list-view" 210 224 > 211 - <svg 212 - xmlns="http://www.w3.org/2000/svg" 213 - fill="none" 214 - viewBox="0 0 24 24" 215 - stroke="currentColor" 216 - > 217 - <path 218 - stroke-linecap="round" 219 - stroke-linejoin="round" 220 - stroke-width="2" 221 - d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" 222 - /> 223 - </svg> 224 - <h3>Open a Repository</h3> 225 - <p>Enter the repository path to browse its contents.</p> 226 - <div 227 - style=" 228 - margin-top: 20px; 229 - max-width: 400px; 230 - margin-left: auto; 231 - margin-right: auto; 232 - " 233 - > 234 - <input 235 - type="text" 236 - x-model="manualRepoPath" 237 - placeholder="did:plc:xxx.../repo-name" 238 - style=" 239 - width: 100%; 240 - margin-bottom: 10px; 241 - padding: 10px; 242 - border: 1px solid #cbd5e1; 243 - border-radius: 6px; 244 - " 245 - @keyup.enter="loadManualRepo" 246 - /> 247 - <button 248 - @click="loadManualRepo" 249 - style="width: 100%" 250 - > 251 - Open Repository 252 - </button> 225 + <div class="repo-list-header"> 226 + <h2 x-text="resolvedHandle || resolvedDid"></h2> 227 + <span 228 + class="repo-count" 229 + x-text="`${repos.length} repos`" 230 + ></span> 231 + </div> 232 + <div class="repo-cards"> 233 + <template x-for="repo in repos" :key="repo.uri"> 234 + <div 235 + class="repo-card" 236 + @click="selectRepository(repo)" 237 + > 238 + <div class="repo-card-header"> 239 + <h3 x-text="repo.name"></h3> 240 + <span 241 + class="repo-knot" 242 + x-text="repo.knot" 243 + ></span> 244 + </div> 245 + <p 246 + x-show="repo.description" 247 + class="repo-card-desc" 248 + x-text="repo.description" 249 + ></p> 250 + <div class="repo-card-meta"> 251 + <template x-if="repo.topics && repo.topics.length > 0"> 252 + <div class="repo-topics"> 253 + <template x-for="topic in repo.topics.slice(0, 5)" :key="topic"> 254 + <span 255 + class="topic-tag" 256 + x-text="topic" 257 + ></span> 258 + </template> 259 + </div> 260 + </template> 261 + </div> 262 + </div> 263 + </template> 253 264 </div> 254 265 </div> 255 266
+106 -42
styles.css
··· 654 654 font-size: 12px; 655 655 } 656 656 657 - .user-item { 658 - margin-bottom: 24px; 659 - } 660 - 661 - .user-header { 662 - font-size: 16px; 663 - font-weight: 600; 664 - color: var(--text-heading); 665 - margin-bottom: 12px; 666 - padding: 12px; 667 - background: var(--bg-tertiary); 668 - border-radius: 6px; 669 - } 670 - 671 - .repo-item { 672 - padding: 12px; 673 - margin-bottom: 8px; 674 - background: var(--bg-secondary); 675 - border: 1px solid var(--border-primary); 676 - border-radius: 6px; 677 - cursor: pointer; 678 - transition: all 0.15s; 679 - } 680 - 681 - .repo-item:hover { 682 - border-color: var(--accent-primary); 683 - box-shadow: 0 2px 4px rgba(59, 130, 246, 0.1); 684 - } 685 - 686 - .repo-item strong { 687 - display: block; 688 - font-size: 14px; 689 - color: var(--text-primary); 690 - margin-bottom: 4px; 691 - } 692 - 693 - .repo-item small { 694 - font-size: 12px; 695 - color: var(--text-tertiary); 696 - font-family: IBMPlexMono, monospace; 697 - } 698 - 699 657 .markdown-content { 700 658 padding: 20px 40px; 701 659 background: var(--bg-secondary); ··· 889 847 color: var(--accent-hover); 890 848 } 891 849 850 + .repo-list-view { 851 + padding: 0; 852 + } 853 + 854 + .repo-list-header { 855 + padding: 20px 24px; 856 + border-bottom: 1px solid var(--border-primary); 857 + display: flex; 858 + align-items: baseline; 859 + gap: 12px; 860 + } 861 + 862 + .repo-list-header h2 { 863 + font-size: 18px; 864 + font-weight: 600; 865 + color: var(--text-heading); 866 + } 867 + 868 + .repo-count { 869 + font-size: 13px; 870 + color: var(--text-tertiary); 871 + } 872 + 873 + .repo-cards { 874 + padding: 12px; 875 + display: grid; 876 + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); 877 + gap: 12px; 878 + } 879 + 880 + .repo-card { 881 + padding: 16px 20px; 882 + background: var(--bg-secondary); 883 + border: 1px solid var(--border-primary); 884 + border-radius: 8px; 885 + cursor: pointer; 886 + transition: all 0.15s; 887 + } 888 + 889 + .repo-card:hover { 890 + border-color: var(--accent-primary); 891 + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.12); 892 + } 893 + 894 + .repo-card-header { 895 + display: flex; 896 + align-items: center; 897 + justify-content: space-between; 898 + gap: 12px; 899 + margin-bottom: 8px; 900 + } 901 + 902 + .repo-card-header h3 { 903 + font-size: 15px; 904 + font-weight: 600; 905 + color: var(--accent-primary); 906 + margin: 0; 907 + } 908 + 909 + .repo-knot { 910 + font-size: 11px; 911 + color: var(--text-tertiary); 912 + background: var(--bg-tertiary); 913 + padding: 2px 8px; 914 + border-radius: 4px; 915 + white-space: nowrap; 916 + font-family: IBMPlexMono, monospace; 917 + } 918 + 919 + .repo-card-desc { 920 + font-size: 13px; 921 + color: var(--text-secondary); 922 + margin: 0 0 10px 0; 923 + line-height: 1.5; 924 + display: -webkit-box; 925 + -webkit-line-clamp: 2; 926 + -webkit-box-orient: vertical; 927 + overflow: hidden; 928 + } 929 + 930 + .repo-card-meta { 931 + display: flex; 932 + align-items: center; 933 + gap: 8px; 934 + flex-wrap: wrap; 935 + } 936 + 937 + .repo-topics { 938 + display: flex; 939 + gap: 6px; 940 + flex-wrap: wrap; 941 + } 942 + 943 + .topic-tag { 944 + font-size: 11px; 945 + padding: 2px 8px; 946 + border-radius: 12px; 947 + background: var(--accent-light); 948 + color: var(--accent-primary); 949 + font-weight: 500; 950 + } 951 + 892 952 @media (max-width: 768px) { 893 953 .main-content { 894 954 flex-direction: column; ··· 912 972 913 973 .welcome-hero { 914 974 padding: 40px 20px; 975 + } 976 + 977 + .repo-cards { 978 + grid-template-columns: 1fr; 915 979 } 916 980 }