Monorepo for Tangled tangled.org

appview/pages: PR mark-as-reviewed btn #1215

merged opened by oyster.cafe targeting master from lt/ap-mark-reviewed-btn
Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mhpzlkussu22
+151 -1
Diff #0
+148 -1
appview/pages/templates/repo/fragments/diff.html
··· 14 14 {{ template "fragments/resizable" }} 15 15 {{ template "activeFileHighlight" }} 16 16 {{ template "fragments/line-quote-button" }} 17 + {{ template "reviewState" }} 17 18 </div> 18 19 {{ end }} 19 20 ··· 37 38 {{ $stat := $diff.Stats }} 38 39 {{ $count := len $diff.ChangedFiles }} 39 40 {{ template "repo/fragments/diffStatPill" $stat }} 40 - <span class="text-xs text-gray-600 dark:text-gray-400 hidden md:inline-flex">{{ $count }} changed file{{ if ne $count 1 }}s{{ end }}</span> 41 + <span id="changed-files-label" class="text-xs text-gray-600 dark:text-gray-400 hidden md:inline-flex" data-total="{{ $count }}">{{ $count }} changed file{{ if ne $count 1 }}s{{ end }}</span> 41 42 42 43 {{ if $root }} 43 44 {{ if $root.IsInterdiff }} ··· 178 179 {{ end }} 179 180 </div> 180 181 </div> 182 + <button 183 + type="button" 184 + data-review-btn="file-{{ .Id }}" 185 + onclick="event.preventDefault(); event.stopPropagation(); window.__reviewToggle && window.__reviewToggle('file-{{ .Id }}')" 186 + class="review-btn hidden p-2 items-center gap-1 text-xs text-gray-400 dark:text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer" 187 + title="Mark as reviewed" 188 + > 189 + <span class="review-icon-unchecked">{{ i "circle" "size-4" }}</span> 190 + <span class="review-icon-checked hidden">{{ i "circle-check" "size-4" }}</span> 191 + <span class="hidden md:inline">reviewed</span> 192 + </button> 181 193 </div> 182 194 </summary> 183 195 ··· 305 317 })(); 306 318 </script> 307 319 {{ end }} 320 + 321 + {{ define "reviewState" }} 322 + <script> 323 + (() => { 324 + const linkBase = document.getElementById('round-link-base'); 325 + if (!linkBase) return; 326 + 327 + const isInterdiff = !!document.getElementById('is-interdiff'); 328 + const basePath = linkBase.value.replace(/^\//, ''); 329 + const storageKey = 'reviewed:' + basePath + (isInterdiff ? '/interdiff' : ''); 330 + 331 + const REVIEWED_PREFIX = 'reviewed:'; 332 + const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; 333 + 334 + const load = () => { 335 + try { 336 + const entry = JSON.parse(localStorage.getItem(storageKey) || '{}'); 337 + return new Set(Array.isArray(entry) ? entry : (entry.files || [])); 338 + } 339 + catch { return new Set(); } 340 + }; 341 + 342 + const save = (reviewed) => { 343 + const liveIds = new Set(Array.from(allFiles()).map(d => d.id)); 344 + localStorage.setItem(storageKey, JSON.stringify({ 345 + files: Array.from(reviewed).filter(id => liveIds.has(id)), 346 + ts: Date.now(), 347 + })); 348 + }; 349 + 350 + const pruneStale = () => { 351 + const now = Date.now(); 352 + Object.keys(localStorage) 353 + .filter(k => k.startsWith(REVIEWED_PREFIX) && k !== storageKey) 354 + .forEach(k => { 355 + try { 356 + const entry = JSON.parse(localStorage.getItem(k)); 357 + if (!entry.ts || now - entry.ts > MAX_AGE_MS) localStorage.removeItem(k); 358 + } catch { localStorage.removeItem(k); } 359 + }); 360 + }; 361 + pruneStale(); 362 + 363 + const allFiles = () => 364 + document.querySelectorAll('details[id^="file-"]'); 365 + 366 + const applyOne = (fileId, isReviewed) => { 367 + const detail = document.getElementById(fileId); 368 + if (!detail) return; 369 + 370 + const btn = detail.querySelector('[data-review-btn]'); 371 + const path = CSS.escape(fileId.replace('file-', '')); 372 + const treeLink = document.querySelector(`.filetree-link[data-path="${path}"]`); 373 + 374 + if (isReviewed) { 375 + detail.classList.add('opacity-60'); 376 + if (btn) { 377 + btn.classList.replace('text-gray-400', 'text-green-600'); 378 + btn.classList.replace('dark:text-gray-500', 'dark:text-green-400'); 379 + btn.querySelector('.review-icon-unchecked')?.classList.add('hidden'); 380 + btn.querySelector('.review-icon-checked')?.classList.remove('hidden'); 381 + } 382 + if (treeLink) { 383 + let indicator = treeLink.parentElement.querySelector('.review-indicator'); 384 + if (!indicator) { 385 + indicator = document.createElement('span'); 386 + indicator.className = 'review-indicator text-green-600 dark:text-green-400 flex-shrink-0'; 387 + indicator.innerHTML = '&#10003;'; 388 + treeLink.parentElement.appendChild(indicator); 389 + } 390 + } 391 + } else { 392 + detail.classList.remove('opacity-60'); 393 + if (btn) { 394 + btn.classList.replace('text-green-600', 'text-gray-400'); 395 + btn.classList.replace('dark:text-green-400', 'dark:text-gray-500'); 396 + btn.querySelector('.review-icon-unchecked')?.classList.remove('hidden'); 397 + btn.querySelector('.review-icon-checked')?.classList.add('hidden'); 398 + } 399 + if (treeLink) { 400 + const indicator = treeLink.parentElement.querySelector('.review-indicator'); 401 + if (indicator) indicator.remove(); 402 + } 403 + } 404 + }; 405 + 406 + const updateProgress = (reviewed) => { 407 + const el = document.getElementById('changed-files-label'); 408 + if (!el) return; 409 + const total = parseInt(el.dataset.total, 10); 410 + const files = allFiles(); 411 + const count = Array.from(files).filter(d => reviewed.has(d.id)).length; 412 + const suffix = total === 1 ? 'file' : 'files'; 413 + const allDone = count === total; 414 + el.classList.toggle('text-green-600', allDone); 415 + el.classList.toggle('dark:text-green-400', allDone); 416 + el.classList.toggle('text-gray-600', !allDone); 417 + el.classList.toggle('dark:text-gray-400', !allDone); 418 + el.textContent = count > 0 419 + ? `${count}/${total} ${suffix} reviewed` 420 + : `${total} changed ${suffix}`; 421 + }; 422 + 423 + const reviewed = load(); 424 + 425 + window.__reviewToggle = (fileId) => { 426 + const detail = document.getElementById(fileId); 427 + if (!detail) return; 428 + const isNowReviewed = !reviewed.has(fileId); 429 + if (isNowReviewed) { 430 + reviewed.add(fileId); 431 + detail.open = false; 432 + } else { 433 + reviewed.delete(fileId); 434 + } 435 + save(reviewed); 436 + applyOne(fileId, isNowReviewed); 437 + updateProgress(reviewed); 438 + }; 439 + 440 + document.querySelectorAll('.review-btn').forEach(btn => { 441 + btn.classList.remove('hidden'); 442 + btn.classList.add('flex'); 443 + }); 444 + 445 + allFiles().forEach(detail => { 446 + if (reviewed.has(detail.id)) { 447 + applyOne(detail.id, true); 448 + detail.open = false; 449 + } 450 + }); 451 + updateProgress(reviewed); 452 + })(); 453 + </script> 454 + {{ end }}
+3
appview/pages/templates/repo/pulls/pull.html
··· 100 100 101 101 {{ define "contentAfter" }} 102 102 <input type="hidden" id="round-link-base" value="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .ActiveRound }}" /> 103 + {{ if .IsInterdiff }} 104 + <input type="hidden" id="is-interdiff" value="1" /> 105 + {{ end }} 103 106 {{ template "repo/fragments/diff" (list .Diff .DiffOpts $) }} 104 107 {{ end }} 105 108

History

2 rounds 3 comments
sign up or login to add to the discussion
1 commit
expand
appview/pages: PR mark-as-reviewed btn
3/3 success
expand
expand 2 comments

types/diff.go:87-87 guarantees the filetree path matches the DOM id

pull request successfully merged
oyster.cafe submitted #0
1 commit
expand
appview/pages: PR mark-as-reviewed btn
3/3 success
expand
expand 1 comment

appview/pages/templates/repo/fragments/diff.html:182 this could probably be hacked into an actual literal checkbox input, but philosophically it's not part of a form it's just a button that does mark a thing as reviewed, and it just so happens to also toggle back in our case 馃し