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