rin
Публичный Open-source lightweight git hosting.
app.js · 1147 строк · 39.2 KB
Raw
1 (function () {
2 'use strict';
3
4 function showToast(message) {
5 var container = document.getElementById('toast-container');
6 if (!container) return;
7 var toast = document.createElement('div');
8 toast.className = 'toast';
9 toast.setAttribute('role', 'status');
10 toast.textContent = message;
11 container.appendChild(toast);
12 setTimeout(function () {
13 if (toast.parentNode) toast.parentNode.removeChild(toast);
14 }, 2800);
15 }
16 window.PolarToast = showToast;
17
18 function copyText(text, successMessage) {
19 var value = text == null ? '' : String(text);
20 if (!value) return;
21 var copiedMsg = successMessage || document.documentElement.getAttribute('data-copied-text') || 'Copied';
22 var failedMsg = document.documentElement.getAttribute('data-copy-failed-text') || 'Could not copy';
23
24 function fallbackCopy() {
25 var previousFocus = document.activeElement;
26 var ta = document.createElement('textarea');
27 ta.value = value;
28 ta.setAttribute('readonly', '');
29 ta.style.position = 'fixed';
30 ta.style.left = '-9999px';
31 ta.style.top = '0';
32 document.body.appendChild(ta);
33 ta.focus();
34 ta.select();
35 ta.setSelectionRange(0, value.length);
36 var ok = false;
37 try {
38 ok = document.execCommand('copy');
39 } catch (err) {
40 ok = false;
41 }
42 if (ta.parentNode) ta.parentNode.removeChild(ta);
43 if (previousFocus && previousFocus.focus) {
44 try { previousFocus.focus({ preventScroll: true }); } catch (err2) {}
45 }
46 showToast(ok ? copiedMsg : failedMsg);
47 }
48
49 if (navigator.clipboard && navigator.clipboard.writeText) {
50 navigator.clipboard.writeText(value).then(function () {
51 showToast(copiedMsg);
52 }, fallbackCopy);
53 } else {
54 fallbackCopy();
55 }
56 }
57
58 function setTheme(theme, silent) {
59 var allowed = ['light', 'dark', 'system'];
60 if (allowed.indexOf(theme) === -1) theme = 'system';
61 document.documentElement.setAttribute('data-theme', theme);
62 try {
63 localStorage.setItem('polar_theme', theme);
64 } catch (e) {}
65 document.cookie = 'polar_theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax';
66
67 document.querySelectorAll('[data-set-theme]').forEach(function (el) {
68 var isCurrent = el.getAttribute('data-set-theme') === theme;
69 el.classList.toggle('active', isCurrent);
70 el.setAttribute('aria-checked', isCurrent ? 'true' : 'false');
71 });
72 updateContextMenuThemeState();
73 }
74 window.PolarSetTheme = setTheme;
75
76 try {
77 var currentAttr = document.documentElement.getAttribute('data-theme') || 'system';
78 document.querySelectorAll('[data-set-theme]').forEach(function (el) {
79 el.classList.toggle('active', el.getAttribute('data-set-theme') === currentAttr);
80 });
81 } catch (e) {}
82
83 function toggleSidebar() {
84 var html = document.documentElement;
85 var isCollapsed = html.getAttribute('data-sidebar') === 'collapsed';
86 if (isCollapsed) {
87 html.removeAttribute('data-sidebar');
88 try {
89 localStorage.setItem('polar_sidebar', 'expanded');
90 } catch (e) {}
91 } else {
92 html.setAttribute('data-sidebar', 'collapsed');
93 try {
94 localStorage.setItem('polar_sidebar', 'collapsed');
95 } catch (e) {}
96 }
97 }
98 window.PolarToggleSidebar = toggleSidebar;
99
100 function closeAllDropdowns() {
101 document.querySelectorAll('[data-dropdown-menu].open').forEach(function (menu) {
102 menu.classList.remove('open');
103 var parent = menu.closest('[data-dropdown]');
104 if (parent) {
105 var btn = parent.querySelector('[data-dropdown-trigger]');
106 if (btn) btn.setAttribute('aria-expanded', 'false');
107 }
108 });
109 }
110
111 var contextMenuIndex = -1;
112 var contextMenuState = null;
113
114 function contextElement(target) {
115 if (!target) return null;
116 if (target.nodeType === 1) return target;
117 return target.parentElement || null;
118 }
119
120 function isEditableTarget(target) {
121 if (!target || !target.tagName) return false;
122 var tag = target.tagName.toUpperCase();
123 return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable;
124 }
125
126 function getContextMenuItems() {
127 var menu = document.getElementById('context-menu');
128 if (!menu) return [];
129 return Array.prototype.filter.call(menu.querySelectorAll('.context-menu-item'), function (item) {
130 var section = item.closest('[data-context-section]');
131 return !section || !section.hidden;
132 });
133 }
134
135 function focusContextMenuItem(index) {
136 var items = getContextMenuItems();
137 if (!items.length) return;
138 contextMenuIndex = (index + items.length) % items.length;
139 items.forEach(function (item, idx) {
140 item.classList.toggle('is-active', idx === contextMenuIndex);
141 });
142 try {
143 items[contextMenuIndex].focus({ preventScroll: true });
144 } catch (err) {
145 items[contextMenuIndex].focus();
146 }
147 }
148
149 function updateContextMenuThemeState() {
150 var menu = document.getElementById('context-menu');
151 if (!menu) return;
152 var theme = document.documentElement.getAttribute('data-theme') || 'system';
153 menu.querySelectorAll('[data-theme-value]').forEach(function (item) {
154 var isCurrent = item.getAttribute('data-theme-value') === theme;
155 item.classList.toggle('is-current', isCurrent);
156 item.setAttribute('aria-checked', isCurrent ? 'true' : 'false');
157 });
158 }
159
160 function closeContextMenu() {
161 var menu = document.getElementById('context-menu');
162 if (!menu || !menu.classList.contains('open')) return;
163 var restoreFocus = contextMenuState && contextMenuState.restoreFocus;
164 menu.classList.remove('open');
165 menu.setAttribute('aria-hidden', 'true');
166 menu.style.left = '';
167 menu.style.top = '';
168 contextMenuState = null;
169 contextMenuIndex = -1;
170 menu.querySelectorAll('.context-menu-item.is-active').forEach(function (item) {
171 item.classList.remove('is-active');
172 });
173 if (restoreFocus && restoreFocus !== document.body && restoreFocus.isConnected) {
174 try { restoreFocus.focus({ preventScroll: true }); } catch (err) {}
175 }
176 }
177
178 function openContextMenuAt(x, y, target, restoreFocus) {
179 var menu = document.getElementById('context-menu');
180 var el = contextElement(target);
181 if (!menu || !el) return;
182
183 closeAllDropdowns();
184
185 var link = el.closest ? el.closest('a[href]') : null;
186 if (link && (!link.getAttribute('href') || /^javascript:/i.test(link.getAttribute('href')))) {
187 link = null;
188 }
189
190 var image = el.closest ? el.closest('img') : null;
191 var imageURL = image ? (image.currentSrc || image.src || '') : '';
192 var imageIsData = /^data:/i.test(imageURL);
193 var imageIsTemporary = /^(data|blob):/i.test(imageURL);
194
195 var selectedText = '';
196 try {
197 var selection = window.getSelection();
198 if (selection && !selection.isCollapsed) selectedText = selection.toString().trim();
199 } catch (err) {}
200
201 var searchText = selectedText.replace(/\s+/g, ' ').trim().slice(0, 160);
202 var linkText = '';
203 if (link) {
204 linkText = (link.innerText || link.textContent || link.getAttribute('aria-label') || link.getAttribute('title') || '')
205 .replace(/\s+/g, ' ').trim();
206 }
207
208 contextMenuState = {
209 linkHref: link ? link.href : '',
210 linkText: linkText,
211 imageURL: imageURL,
212 imageIsData: imageIsData,
213 selectedText: selectedText,
214 searchText: searchText,
215 restoreFocus: restoreFocus || document.activeElement
216 };
217
218 ['selection', 'link', 'image'].forEach(function (name) {
219 var section = menu.querySelector('[data-context-section="' + name + '"]');
220 if (section) {
221 section.hidden = name === 'selection' ? !selectedText : name === 'link' ? !link : !imageURL;
222 }
223 });
224
225 var linkTextItem = menu.querySelector('[data-context-action="link-text"]');
226 if (linkTextItem) linkTextItem.hidden = !linkText;
227 var imageOpenItem = menu.querySelector('[data-context-action="image-open"]');
228 if (imageOpenItem) imageOpenItem.hidden = !imageURL || imageIsData;
229 var imageCopyItem = menu.querySelector('[data-context-action="image-copy"]');
230 if (imageCopyItem) imageCopyItem.hidden = !imageURL || imageIsTemporary;
231
232 updateContextMenuThemeState();
233 menu.classList.add('open');
234 menu.setAttribute('aria-hidden', 'false');
235 menu.scrollTop = 0;
236 menu.style.left = '0px';
237 menu.style.top = '0px';
238
239 var menuWidth = menu.offsetWidth;
240 var menuHeight = menu.offsetHeight;
241 var gap = 8;
242 var vw = window.innerWidth || document.documentElement.clientWidth;
243 var vh = window.innerHeight || document.documentElement.clientHeight;
244 var left = x;
245 var top = y;
246 if (left + menuWidth > vw - gap) left = x - menuWidth;
247 if (left < gap) left = gap;
248 if (left + menuWidth > vw - gap) left = Math.max(gap, vw - menuWidth - gap);
249 if (top + menuHeight > vh - gap) top = y - menuHeight;
250 if (top < gap) top = gap;
251 if (top + menuHeight > vh - gap) top = Math.max(gap, vh - menuHeight - gap);
252 menu.style.left = Math.round(left) + 'px';
253 menu.style.top = Math.round(top) + 'px';
254
255 contextMenuIndex = -1;
256 setTimeout(function () {
257 if (menu.classList.contains('open')) focusContextMenuItem(0);
258 }, 20);
259 }
260
261 function openContextMenuForKeyboard() {
262 var target = document.activeElement;
263 if (!target || target === document.body) target = document.body;
264 var rect = target.getBoundingClientRect ? target.getBoundingClientRect() : null;
265 var x = rect ? rect.left + Math.min(rect.width, 20) : 16;
266 var y = rect ? rect.bottom + 4 : 16;
267 openContextMenuAt(x, y, target, target);
268 }
269
270 function runContextMenuAction(item) {
271 var action = item.getAttribute('data-context-action');
272 var state = contextMenuState || {};
273
274 if (action === 'theme') {
275 setTheme(item.getAttribute('data-theme-value'), false);
276 closeContextMenu();
277 return;
278 }
279
280 closeContextMenu();
281
282 if (action === 'copy-selection') {
283 copyText(state.selectedText);
284 } else if (action === 'search-selection') {
285 openPalette(state.searchText || state.selectedText || '');
286 } else if (action === 'link-open') {
287 if (state.linkHref) window.open(state.linkHref, '_blank', 'noopener,noreferrer');
288 } else if (action === 'link-copy') {
289 copyText(state.linkHref);
290 } else if (action === 'link-text') {
291 copyText(state.linkText);
292 } else if (action === 'image-open') {
293 if (state.imageURL) window.open(state.imageURL, '_blank', 'noopener,noreferrer');
294 } else if (action === 'image-copy') {
295 copyText(state.imageURL);
296 } else if (action === 'image-save') {
297 if (state.imageURL) {
298 var extension = 'img';
299 var dataMatch = state.imageURL.match(/^data:image\/([a-z0-9.+-]+)/i);
300 if (dataMatch) {
301 extension = dataMatch[1].toLowerCase().replace('jpeg', 'jpg').replace('svg+xml', 'svg');
302 } else {
303 try {
304 var pathMatch = new URL(state.imageURL).pathname.match(/\.([a-z0-9]{2,5})$/i);
305 if (pathMatch) extension = pathMatch[1].toLowerCase();
306 } catch (err) {}
307 }
308 var download = document.createElement('a');
309 download.href = state.imageURL;
310 download.download = 'polar-image.' + extension;
311 document.body.appendChild(download);
312 download.click();
313 document.body.removeChild(download);
314 }
315 } else if (action === 'page-copy') {
316 copyText(window.location.href);
317 } else if (action === 'page-reload') {
318 window.location.reload();
319 } else if (action === 'page-back') {
320 window.history.back();
321 } else if (action === 'page-forward') {
322 window.history.forward();
323 } else if (action === 'palette') {
324 openPalette('');
325 } else if (action === 'shortcuts') {
326 openDialog('shortcuts-dialog');
327 }
328 }
329
330 function cycleTheme() {
331 var current = document.documentElement.getAttribute('data-theme') || 'system';
332 var order = ['light', 'dark', 'system'];
333 var next = order[(order.indexOf(current) + 1) % order.length];
334 setTheme(next, false);
335 var item = document.querySelector('#context-menu [data-theme-value="' + next + '"]');
336 var label = item ? item.querySelector('span:not(.context-menu-check)') : null;
337 showToast(label ? label.textContent.trim() : next);
338 }
339
340 function shortcutUnavailable() {
341 showToast(document.documentElement.getAttribute('data-shortcut-unavailable') || 'Shortcut unavailable here');
342 }
343
344 function handleNavigationChord(key) {
345 var body = document.body;
346 var repoPath = body ? body.getAttribute('data-repo-path') : '';
347 var currentUser = body ? body.getAttribute('data-current-user') : '';
348 var repoFlags = {
349 issues: body && body.getAttribute('data-repo-issues') === 'true',
350 pulls: body && body.getAttribute('data-repo-pulls') === 'true',
351 releases: body && body.getAttribute('data-repo-releases') === 'true',
352 activity: body && body.getAttribute('data-repo-activity') === 'true',
353 canWrite: body && body.getAttribute('data-repo-can-write') === 'true'
354 };
355
356 if (key === 'h') {
357 window.location.href = '/';
358 return true;
359 }
360 if (key === 'r') {
361 window.location.href = '/explore';
362 return true;
363 }
364 if (key === 'o') {
365 if (!currentUser) {
366 shortcutUnavailable();
367 return true;
368 }
369 window.location.href = '/' + encodeURIComponent(currentUser);
370 return true;
371 }
372 if (key === 'n') {
373 if (!currentUser) {
374 shortcutUnavailable();
375 return true;
376 }
377 window.location.href = '/new';
378 return true;
379 }
380 if (key === 's' && !repoPath) {
381 if (currentUser) {
382 window.location.href = '/settings';
383 } else {
384 shortcutUnavailable();
385 }
386 return true;
387 }
388 if (!repoPath) {
389 var isRepoChord = 'ciplab'.indexOf(key) !== -1;
390 if (isRepoChord) shortcutUnavailable();
391 return isRepoChord;
392 }
393 if (key === 'c') {
394 window.location.href = repoPath;
395 return true;
396 }
397 if (key === 'i') {
398 if (!repoFlags.issues) shortcutUnavailable();
399 else window.location.href = repoPath + '/issues';
400 return true;
401 }
402 if (key === 'p') {
403 if (!repoFlags.pulls) shortcutUnavailable();
404 else window.location.href = repoPath + '/pulls';
405 return true;
406 }
407 if (key === 'l') {
408 if (!repoFlags.releases) shortcutUnavailable();
409 else window.location.href = repoPath + '/releases';
410 return true;
411 }
412 if (key === 'a') {
413 if (!repoFlags.activity) shortcutUnavailable();
414 else window.location.href = repoPath + '/activity';
415 return true;
416 }
417 if (key === 'b') {
418 window.location.href = repoPath + '/branches';
419 return true;
420 }
421 if (key === 's') {
422 if (!repoFlags.canWrite) shortcutUnavailable();
423 else window.location.href = repoPath + '/settings';
424 return true;
425 }
426 return false;
427 }
428
429 document.addEventListener('contextmenu', function (e) {
430 var el = contextElement(e.target);
431 if (!el || el.closest('#context-menu') || el.isContentEditable || el.closest('input, textarea, select, [data-native-context]')) {
432 return;
433 }
434 e.preventDefault();
435 openContextMenuAt(e.clientX, e.clientY, el, document.activeElement);
436 });
437
438 document.addEventListener('pointerdown', function (e) {
439 var menu = document.getElementById('context-menu');
440 if (!menu || !menu.classList.contains('open')) return;
441 if (!e.target.closest || !e.target.closest('#context-menu')) closeContextMenu();
442 }, true);
443
444 document.addEventListener('scroll', function (e) {
445 var target = contextElement(e.target);
446 if (target && target.closest && target.closest('#context-menu')) return;
447 closeContextMenu();
448 }, true);
449 window.addEventListener('resize', closeContextMenu);
450 window.addEventListener('popstate', closeContextMenu);
451 window.addEventListener('hashchange', closeContextMenu);
452 document.addEventListener('htmx:beforeSwap', closeContextMenu);
453
454 function openDialog(id) {
455 var dlg = document.getElementById(id);
456 if (!dlg) return;
457 closeAllDropdowns();
458 closeContextMenu();
459 dlg.classList.add('open');
460 dlg.setAttribute('aria-hidden', 'false');
461 var firstInput = dlg.querySelector('input:not([type="hidden"]), button[data-autofocus]');
462 if (firstInput) {
463 setTimeout(function () { firstInput.focus(); }, 20);
464 }
465 }
466 window.PolarOpenDialog = openDialog;
467
468 function closeDialog(dlg) {
469 if (!dlg) return;
470 dlg.classList.remove('open');
471 dlg.setAttribute('aria-hidden', 'true');
472 }
473
474 // Command Palette logic
475 var paletteIndex = 0;
476
477 function getVisiblePaletteItems() {
478 var list = document.querySelectorAll('#command-palette [data-palette-item]');
479 var visible = [];
480 list.forEach(function (item) {
481 if (item.style.display !== 'none') visible.push(item);
482 });
483 return visible;
484 }
485
486 function updatePaletteSelection() {
487 var items = getVisiblePaletteItems();
488 if (paletteIndex >= items.length) paletteIndex = 0;
489 if (paletteIndex < 0) paletteIndex = Math.max(0, items.length - 1);
490 items.forEach(function (el, idx) {
491 el.classList.toggle('selected', idx === paletteIndex);
492 if (idx === paletteIndex) {
493 el.scrollIntoView({ block: 'nearest' });
494 }
495 });
496 }
497
498 function filterPalette(query) {
499 var q = (query || '').trim().toLowerCase();
500 var allItems = document.querySelectorAll('#command-palette [data-palette-item]');
501 allItems.forEach(function (item) {
502 var text = (item.getAttribute('data-palette-keywords') || item.textContent || '').toLowerCase();
503 var show = q === '' || text.indexOf(q) !== -1;
504 item.style.display = show ? '' : 'none';
505 });
506 document.querySelectorAll('#command-palette [data-palette-group]').forEach(function (group) {
507 var hasVisible = false;
508 group.querySelectorAll('[data-palette-item]').forEach(function (it) {
509 if (it.style.display !== 'none') hasVisible = true;
510 });
511 group.style.display = hasVisible ? '' : 'none';
512 });
513 paletteIndex = 0;
514 updatePaletteSelection();
515 }
516
517 function openPalette(initialQuery) {
518 var palette = document.getElementById('command-palette');
519 if (!palette) return;
520 closeAllDropdowns();
521 closeContextMenu();
522 palette.classList.add('open');
523 palette.setAttribute('aria-hidden', 'false');
524 var input = document.getElementById('command-palette-input');
525 if (input) {
526 input.value = initialQuery || '';
527 filterPalette(input.value);
528 setTimeout(function () {
529 input.focus();
530 if (initialQuery) input.select();
531 }, 20);
532 }
533 }
534 window.PolarOpenPalette = openPalette;
535
536 function activatePaletteItem(item) {
537 if (!item) return;
538 var palette = document.getElementById('command-palette');
539 var href = item.getAttribute('data-palette-href');
540 var theme = item.getAttribute('data-palette-theme');
541 var action = item.getAttribute('data-palette-action');
542 if (palette) closeDialog(palette);
543
544 if (theme) {
545 setTheme(theme, false);
546 } else if (action === 'shortcuts') {
547 openDialog('shortcuts-dialog');
548 } else if (action === 'toggle-sidebar') {
549 toggleSidebar();
550 } else if (href) {
551 window.location.href = href;
552 }
553 }
554
555 document.addEventListener('click', function (e) {
556 // Context menu action
557 var contextAction = e.target.closest('[data-context-action]');
558 if (contextAction) {
559 e.preventDefault();
560 runContextMenuAction(contextAction);
561 return;
562 }
563
564 // Context menu navigation links
565 if (e.target.closest('#context-menu a[href]')) {
566 closeContextMenu();
567 return;
568 }
569 if (e.target.closest('#context-menu')) return;
570
571 // Toggle sidebar
572 var sbBtn = e.target.closest('[data-toggle-sidebar]');
573 if (sbBtn) {
574 e.preventDefault();
575 toggleSidebar();
576 return;
577 }
578
579 // Dropdown trigger
580 var trigger = e.target.closest('[data-dropdown-trigger]');
581 if (trigger) {
582 e.stopPropagation();
583 var dropdown = trigger.closest('[data-dropdown]');
584 var menu = dropdown ? dropdown.querySelector('[data-dropdown-menu]') : null;
585 var wasOpen = menu && menu.classList.contains('open');
586 closeAllDropdowns();
587 if (menu && !wasOpen) {
588 menu.classList.add('open');
589 trigger.setAttribute('aria-expanded', 'true');
590 }
591 return;
592 }
593
594 // Theme button
595 var themeBtn = e.target.closest('[data-set-theme]');
596 if (themeBtn) {
597 e.preventDefault();
598 setTheme(themeBtn.getAttribute('data-set-theme'), false);
599 closeAllDropdowns();
600 return;
601 }
602
603 // Open Command Palette
604 var paletteTrigger = e.target.closest('[data-open-palette]');
605 if (paletteTrigger) {
606 e.preventDefault();
607 openPalette(paletteTrigger.getAttribute('data-open-palette') || '');
608 return;
609 }
610
611 // Palette item click
612 var paletteItem = e.target.closest('[data-palette-item]');
613 if (paletteItem) {
614 e.preventDefault();
615 activatePaletteItem(paletteItem);
616 return;
617 }
618
619 // Dialog open
620 var dlgOpen = e.target.closest('[data-dialog-open]');
621 if (dlgOpen) {
622 e.preventDefault();
623 openDialog(dlgOpen.getAttribute('data-dialog-open'));
624 return;
625 }
626
627 // Dialog close
628 var dlgClose = e.target.closest('[data-dialog-close]');
629 if (dlgClose) {
630 e.preventDefault();
631 closeDialog(dlgClose.closest('.backdrop'));
632 return;
633 }
634
635 // Backdrop click
636 if (e.target.classList.contains('backdrop')) {
637 closeDialog(e.target);
638 return;
639 }
640
641 // Mobile drawer
642 var drawerOpen = e.target.closest('[data-drawer-open]');
643 if (drawerOpen) {
644 var drw = document.getElementById('mobile-drawer');
645 if (drw) drw.classList.add('open');
646 return;
647 }
648 var drawerClose = e.target.closest('[data-drawer-close]');
649 if (drawerClose || e.target.id === 'mobile-drawer') {
650 var drw2 = document.getElementById('mobile-drawer');
651 if (drw2) drw2.classList.remove('open');
652 return;
653 }
654
655 // Copy to clipboard
656 var copyBtn = e.target.closest('[data-copy]');
657 if (copyBtn) {
658 e.preventDefault();
659 copyText(copyBtn.getAttribute('data-copy'));
660 return;
661 }
662
663 if (!e.target.closest('[data-dropdown-menu]')) {
664 closeAllDropdowns();
665 }
666 });
667
668 // Input handlers (palette filter + delete repo confirmation match)
669 document.addEventListener('input', function (e) {
670 if (e.target.id === 'command-palette-input') {
671 filterPalette(e.target.value);
672 return;
673 }
674
675 var matchExpected = e.target.getAttribute('data-confirm-input');
676 if (matchExpected !== null) {
677 var btnId = e.target.getAttribute('data-confirm-button');
678 var btn = btnId ? document.getElementById(btnId) : null;
679 if (btn) {
680 btn.disabled = e.target.value.trim() !== matchExpected;
681 }
682 }
683
684 if (e.target.hasAttribute('data-file-filter')) {
685 var q = e.target.value.trim().toLowerCase();
686 document.querySelectorAll('[data-tree-row]').forEach(function (row) {
687 var name = (row.getAttribute('data-tree-name') || '').toLowerCase();
688 row.style.display = !q || name.indexOf(q) !== -1 ? '' : 'none';
689 });
690 }
691 });
692
693 // Keyboard navigation & chords
694 var lastChordKey = '';
695 var lastChordTime = 0;
696
697 document.addEventListener('keydown', function (e) {
698 var palette = document.getElementById('command-palette');
699 var isPaletteOpen = palette && palette.classList.contains('open');
700 var contextMenu = document.getElementById('context-menu');
701 var isContextMenuOpen = contextMenu && contextMenu.classList.contains('open');
702
703 if (isContextMenuOpen) {
704 if (e.key === 'Escape') {
705 e.preventDefault();
706 closeContextMenu();
707 return;
708 }
709 if (e.key === 'ArrowDown') {
710 e.preventDefault();
711 focusContextMenuItem(contextMenuIndex + 1);
712 return;
713 }
714 if (e.key === 'ArrowUp') {
715 e.preventDefault();
716 focusContextMenuItem(contextMenuIndex - 1);
717 return;
718 }
719 if (e.key === 'Home') {
720 e.preventDefault();
721 focusContextMenuItem(0);
722 return;
723 }
724 if (e.key === 'End') {
725 e.preventDefault();
726 focusContextMenuItem(getContextMenuItems().length - 1);
727 return;
728 }
729 if (e.key === 'Tab') {
730 closeContextMenu();
731 return;
732 }
733 if (!e.ctrlKey && !e.metaKey && !e.altKey) return;
734 }
735
736 if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
737 e.preventDefault();
738 cycleTheme();
739 return;
740 }
741
742 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
743 e.preventDefault();
744 if (isPaletteOpen) {
745 closeDialog(palette);
746 } else {
747 openPalette('');
748 }
749 return;
750 }
751
752 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') {
753 e.preventDefault();
754 toggleSidebar();
755 return;
756 }
757
758 if (e.key === 'Escape') {
759 closeContextMenu();
760 closeAllDropdowns();
761 document.querySelectorAll('.backdrop.open').forEach(function (b) {
762 closeDialog(b);
763 });
764 var drw = document.getElementById('mobile-drawer');
765 if (drw) drw.classList.remove('open');
766 return;
767 }
768
769 if (isPaletteOpen) {
770 var items = getVisiblePaletteItems();
771 if (e.key === 'ArrowDown') {
772 e.preventDefault();
773 paletteIndex = (paletteIndex + 1) % Math.max(1, items.length);
774 updatePaletteSelection();
775 } else if (e.key === 'ArrowUp') {
776 e.preventDefault();
777 paletteIndex = (paletteIndex - 1 + Math.max(1, items.length)) % Math.max(1, items.length);
778 updatePaletteSelection();
779 } else if (e.key === 'Enter') {
780 e.preventDefault();
781 if (items[paletteIndex]) activatePaletteItem(items[paletteIndex]);
782 }
783 return;
784 }
785
786 if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') {
787 if (isEditableTarget(e.target)) return;
788 e.preventDefault();
789 openContextMenuForKeyboard();
790 return;
791 }
792
793 if (isEditableTarget(e.target)) return;
794 if (e.ctrlKey || e.metaKey || e.altKey) return;
795
796 var key = e.key.toLowerCase();
797 var now = Date.now();
798 if (lastChordKey === 'g' && now - lastChordTime < 900) {
799 lastChordKey = '';
800 lastChordTime = 0;
801 if (handleNavigationChord(key)) {
802 e.preventDefault();
803 return;
804 }
805 } else if (key !== 'g') {
806 lastChordKey = '';
807 }
808
809 if (key === 'g') {
810 lastChordKey = 'g';
811 lastChordTime = now;
812 return;
813 }
814
815 if (key === 'y') {
816 e.preventDefault();
817 copyText(window.location.href);
818 return;
819 }
820
821 if (e.key === '/') {
822 e.preventDefault();
823 var searchInput = document.querySelector('[data-search-input]');
824 if (searchInput) {
825 searchInput.focus();
826 } else {
827 openPalette('');
828 }
829 return;
830 }
831
832 if (e.key === '?') {
833 e.preventDefault();
834 openDialog('shortcuts-dialog');
835 return;
836 }
837
838 if (key === 't') {
839 e.preventDefault();
840 var fileFilter = document.querySelector('[data-file-filter]');
841 if (fileFilter) {
842 fileFilter.focus();
843 } else {
844 openPalette('file:');
845 }
846 }
847 });
848
849 // Avatar file picker preview & client-side 256x256 square crop/resize
850 document.addEventListener('change', function (e) {
851 if (e.target && e.target.id === 'avatar-file-input') {
852 var file = e.target.files && e.target.files[0];
853 if (!file) return;
854 var reader = new FileReader();
855 reader.onload = function (ev) {
856 var img = new Image();
857 img.onload = function () {
858 var size = 256;
859 var canvas = document.createElement('canvas');
860 canvas.width = size;
861 canvas.height = size;
862 var ctx = canvas.getContext('2d');
863 var minSide = Math.min(img.width, img.height);
864 var sx = (img.width - minSide) / 2;
865 var sy = (img.height - minSide) / 2;
866 ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, size, size);
867 var dataUrl = canvas.toDataURL('image/webp', 0.88);
868 if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) {
869 dataUrl = canvas.toDataURL('image/jpeg', 0.88);
870 }
871 var hidden = document.getElementById('avatar-data-input');
872 var rmHidden = document.getElementById('remove-avatar-input');
873 if (hidden) hidden.value = dataUrl;
874 if (rmHidden) rmHidden.value = 'false';
875 var previewWrap = document.getElementById('avatar-preview-wrap');
876 if (previewWrap) {
877 previewWrap.innerHTML = '<span class="avatar avatar-xl"><img src="' + dataUrl + '" alt="Avatar" class="avatar-img"></span>';
878 }
879 };
880 img.src = ev.target.result;
881 };
882 reader.readAsDataURL(file);
883 }
884 });
885
886 document.addEventListener('click', function (e) {
887 var rmBtn = e.target.closest('#remove-avatar-btn');
888 if (rmBtn) {
889 e.preventDefault();
890 var rmHidden = document.getElementById('remove-avatar-input');
891 var hidden = document.getElementById('avatar-data-input');
892 var fileInput = document.getElementById('avatar-file-input');
893 if (rmHidden) rmHidden.value = 'true';
894 if (hidden) hidden.value = '';
895 if (fileInput) fileInput.value = '';
896 var previewWrap = document.getElementById('avatar-preview-wrap');
897 if (previewWrap) {
898 previewWrap.innerHTML = '<span class="avatar avatar-xl">?</span>';
899 }
900 rmBtn.style.display = 'none';
901 }
902 });
903
904 function resizeCoverImage(file, width, height, callback) {
905 var reader = new FileReader();
906 reader.onload = function (ev) {
907 var img = new Image();
908 img.onload = function () {
909 var canvas = document.createElement('canvas');
910 canvas.width = width;
911 canvas.height = height;
912 var ctx = canvas.getContext('2d');
913 var scale = Math.max(width / img.width, height / img.height);
914 var sw = width / scale;
915 var sh = height / scale;
916 var sx = (img.width - sw) / 2;
917 var sy = (img.height - sh) / 2;
918 ctx.drawImage(img, sx, sy, sw, sh, 0, 0, width, height);
919 var dataUrl = canvas.toDataURL('image/webp', 0.84);
920 if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) {
921 dataUrl = canvas.toDataURL('image/jpeg', 0.84);
922 }
923 callback(dataUrl);
924 };
925 img.src = ev.target.result;
926 };
927 reader.readAsDataURL(file);
928 }
929
930 function resizeSquareImage(file, size, callback) {
931 var reader = new FileReader();
932 reader.onload = function (ev) {
933 var img = new Image();
934 img.onload = function () {
935 var canvas = document.createElement('canvas');
936 canvas.width = size;
937 canvas.height = size;
938 var ctx = canvas.getContext('2d');
939 var minSide = Math.min(img.width, img.height);
940 var sx = (img.width - minSide) / 2;
941 var sy = (img.height - minSide) / 2;
942 ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, size, size);
943 var dataUrl = canvas.toDataURL('image/webp', 0.88);
944 if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) {
945 dataUrl = canvas.toDataURL('image/jpeg', 0.88);
946 }
947 callback(dataUrl);
948 };
949 img.src = ev.target.result;
950 };
951 reader.readAsDataURL(file);
952 }
953
954 document.addEventListener('change', function (e) {
955 if (!e.target) return;
956 if (e.target.id === 'background-file-input') {
957 var bgFile = e.target.files && e.target.files[0];
958 if (!bgFile) return;
959 resizeCoverImage(bgFile, 1600, 700, function (dataUrl) {
960 var hidden = document.getElementById('background-data-input');
961 var remove = document.getElementById('remove-background-input');
962 var preview = document.getElementById('background-preview');
963 if (hidden) hidden.value = dataUrl;
964 if (remove) remove.value = 'false';
965 if (preview) {
966 preview.classList.remove('is-empty');
967 preview.innerHTML = '<img src="' + dataUrl + '" alt="">';
968 }
969 });
970 return;
971 }
972 if (e.target.id === 'repo-background-file-input') {
973 var repoBgFile = e.target.files && e.target.files[0];
974 if (!repoBgFile) return;
975 resizeCoverImage(repoBgFile, 1600, 700, function (dataUrl) {
976 var hidden = document.getElementById('repo-background-data-input');
977 var remove = document.getElementById('remove-repo-background-input');
978 var preview = document.getElementById('repo-background-preview');
979 if (hidden) hidden.value = dataUrl;
980 if (remove) remove.value = 'false';
981 if (preview) {
982 preview.classList.remove('is-empty');
983 preview.innerHTML = '<img src="' + dataUrl + '" alt="">';
984 }
985 });
986 return;
987 }
988 if (e.target.id === 'repo-avatar-file-input') {
989 var repoFile = e.target.files && e.target.files[0];
990 if (!repoFile) return;
991 resizeSquareImage(repoFile, 256, function (dataUrl) {
992 var hidden = document.getElementById('repo-avatar-data-input');
993 var remove = document.getElementById('remove-repo-avatar-input');
994 var preview = document.getElementById('repo-avatar-preview-wrap');
995 if (hidden) hidden.value = dataUrl;
996 if (remove) remove.value = 'false';
997 if (preview) {
998 preview.innerHTML = '<span class="avatar avatar-xl"><img src="' + dataUrl + '" alt="Repository avatar" class="avatar-img"></span>';
999 }
1000 });
1001 }
1002 });
1003
1004 document.addEventListener('click', function (e) {
1005 var removeBg = e.target.closest('#remove-background-btn');
1006 if (removeBg) {
1007 e.preventDefault();
1008 var hidden = document.getElementById('background-data-input');
1009 var remove = document.getElementById('remove-background-input');
1010 var fileInput = document.getElementById('background-file-input');
1011 var preview = document.getElementById('background-preview');
1012 if (hidden) hidden.value = '';
1013 if (remove) remove.value = 'true';
1014 if (fileInput) fileInput.value = '';
1015 if (preview) {
1016 preview.classList.add('is-empty');
1017 preview.innerHTML = '<span class="background-preview-placeholder"><svg class="icon size-5" aria-hidden="true"><use href="#hi-image"></use></svg></span>';
1018 }
1019 removeBg.style.display = 'none';
1020 return;
1021 }
1022
1023 var removeRepoBg = e.target.closest('#remove-repo-background-btn');
1024 if (removeRepoBg) {
1025 e.preventDefault();
1026 var hidden = document.getElementById('repo-background-data-input');
1027 var remove = document.getElementById('remove-repo-background-input');
1028 var fileInput = document.getElementById('repo-background-file-input');
1029 var preview = document.getElementById('repo-background-preview');
1030 if (hidden) hidden.value = '';
1031 if (remove) remove.value = 'true';
1032 if (fileInput) fileInput.value = '';
1033 if (preview) {
1034 preview.classList.add('is-empty');
1035 preview.innerHTML = '<span class="background-preview-placeholder"><svg class="icon size-5" aria-hidden="true"><use href="#hi-image"></use></svg></span>';
1036 }
1037 removeRepoBg.style.display = 'none';
1038 return;
1039 }
1040
1041 var removeRepoAvatar = e.target.closest('#remove-repo-avatar-btn');
1042 if (removeRepoAvatar) {
1043 e.preventDefault();
1044 var hidden = document.getElementById('repo-avatar-data-input');
1045 var remove = document.getElementById('remove-repo-avatar-input');
1046 var fileInput = document.getElementById('repo-avatar-file-input');
1047 var preview = document.getElementById('repo-avatar-preview-wrap');
1048 if (hidden) hidden.value = '';
1049 if (remove) remove.value = 'true';
1050 if (fileInput) fileInput.value = '';
1051 if (preview) preview.innerHTML = '<span class="avatar avatar-xl">?</span>';
1052 removeRepoAvatar.style.display = 'none';
1053 return;
1054 }
1055
1056 var deleteUser = e.target.closest('[data-admin-delete-user]');
1057 if (deleteUser) {
1058 e.preventDefault();
1059 var name = deleteUser.getAttribute('data-admin-delete-name') || '';
1060 var userForm = document.getElementById('admin-delete-user-form');
1061 var userInput = document.getElementById('admin_delete_confirm_user');
1062 var userTarget = document.getElementById('admin-delete-user-target');
1063 var userSubmit = document.getElementById('admin-delete-user-submit');
1064 if (userForm) userForm.setAttribute('action', '/admin/users/' + deleteUser.getAttribute('data-admin-delete-user') + '/delete');
1065 if (userTarget) userTarget.textContent = '@' + name;
1066 if (userInput) {
1067 userInput.value = '';
1068 userInput.setAttribute('data-confirm-input', name);
1069 }
1070 if (userSubmit) userSubmit.disabled = true;
1071 openDialog('admin-delete-user-dialog');
1072 return;
1073 }
1074
1075 var deleteRepo = e.target.closest('[data-admin-delete-repo]');
1076 if (deleteRepo) {
1077 e.preventDefault();
1078 var repoName = deleteRepo.getAttribute('data-admin-delete-name') || '';
1079 var repoForm = document.getElementById('admin-delete-repo-form');
1080 var repoInput = document.getElementById('admin_delete_confirm_repo');
1081 var repoTarget = document.getElementById('admin-delete-repo-target');
1082 var repoSubmit = document.getElementById('admin-delete-repo-submit');
1083 if (repoForm) repoForm.setAttribute('action', '/admin/repos/' + deleteRepo.getAttribute('data-admin-delete-repo') + '/delete');
1084 if (repoTarget) repoTarget.textContent = repoName;
1085 if (repoInput) {
1086 repoInput.value = '';
1087 repoInput.setAttribute('data-confirm-input', repoName);
1088 }
1089 if (repoSubmit) repoSubmit.disabled = true;
1090 openDialog('admin-delete-repo-dialog');
1091 }
1092 });
1093
1094 // Repo header: the description stays on the title line and expands onto its
1095 // own line when clicked. The avatar is sized to the title line so it always
1096 // sits on the same row as the repository name, at a modest size.
1097 var repoAvatarTimer = null;
1098
1099 function sizeRepoAvatars() {
1100 var changed = false;
1101 document.querySelectorAll('.repo-banner').forEach(function (banner) {
1102 var avatar = banner.querySelector('.repo-avatar');
1103 var title = banner.querySelector('.repo-head-row nav');
1104 if (!avatar || !title) return;
1105 var h = Math.round(title.getBoundingClientRect().height);
1106 if (h <= 0) return;
1107 var size = Math.min(30, Math.max(18, h));
1108 var sizePx = size + 'px';
1109 if (avatar.style.width === sizePx) return;
1110 avatar.style.width = sizePx;
1111 avatar.style.height = sizePx;
1112 avatar.style.fontSize = Math.min(20, Math.max(9, Math.round(size * 0.42))) + 'px';
1113 changed = true;
1114 });
1115 return changed;
1116 }
1117
1118 function updateRepoDescs() {
1119 document.querySelectorAll('[data-repo-desc]').forEach(function (el) {
1120 var banner = el.closest('.repo-banner');
1121 if (banner && banner.classList.contains('desc-open')) {
1122 el.classList.add('truncated');
1123 return;
1124 }
1125 el.classList.toggle('truncated', el.scrollWidth > el.clientWidth + 1);
1126 });
1127 if (sizeRepoAvatars()) {
1128 // Re-measure once the width transition settles: the final avatar width
1129 // changes the room left for the description.
1130 window.clearTimeout(repoAvatarTimer);
1131 repoAvatarTimer = window.setTimeout(sizeRepoAvatars, 300);
1132 }
1133 }
1134
1135 document.addEventListener('click', function (e) {
1136 var desc = e.target.closest('[data-repo-desc]');
1137 if (!desc || !desc.classList.contains('truncated')) return;
1138 var banner = desc.closest('.repo-banner');
1139 if (!banner) return;
1140 banner.classList.toggle('desc-open');
1141 updateRepoDescs();
1142 });
1143
1144 window.addEventListener('resize', updateRepoDescs);
1145 document.addEventListener('htmx:afterSwap', updateRepoDescs);
1146 updateRepoDescs();
1147 })();