rin
Public Open-source lightweight git hosting.
app.js · 519 lines · 16.6 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 setTheme(theme, silent) {
19 var allowed = ['light', 'dark', 'system'];
20 if (allowed.indexOf(theme) === -1) theme = 'system';
21 document.documentElement.setAttribute('data-theme', theme);
22 try {
23 localStorage.setItem('polar_theme', theme);
24 } catch (e) {}
25 document.cookie = 'polar_theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax';
26
27 document.querySelectorAll('[data-set-theme]').forEach(function (el) {
28 var isCurrent = el.getAttribute('data-set-theme') === theme;
29 el.classList.toggle('active', isCurrent);
30 el.setAttribute('aria-checked', isCurrent ? 'true' : 'false');
31 });
32 }
33 window.PolarSetTheme = setTheme;
34
35 try {
36 var currentAttr = document.documentElement.getAttribute('data-theme') || 'system';
37 document.querySelectorAll('[data-set-theme]').forEach(function (el) {
38 el.classList.toggle('active', el.getAttribute('data-set-theme') === currentAttr);
39 });
40 } catch (e) {}
41
42 function toggleSidebar() {
43 var html = document.documentElement;
44 var isCollapsed = html.getAttribute('data-sidebar') === 'collapsed';
45 if (isCollapsed) {
46 html.removeAttribute('data-sidebar');
47 try {
48 localStorage.setItem('polar_sidebar', 'expanded');
49 } catch (e) {}
50 } else {
51 html.setAttribute('data-sidebar', 'collapsed');
52 try {
53 localStorage.setItem('polar_sidebar', 'collapsed');
54 } catch (e) {}
55 }
56 }
57 window.PolarToggleSidebar = toggleSidebar;
58
59 function closeAllDropdowns() {
60 document.querySelectorAll('[data-dropdown-menu].open').forEach(function (menu) {
61 menu.classList.remove('open');
62 var parent = menu.closest('[data-dropdown]');
63 if (parent) {
64 var btn = parent.querySelector('[data-dropdown-trigger]');
65 if (btn) btn.setAttribute('aria-expanded', 'false');
66 }
67 });
68 }
69
70 function openDialog(id) {
71 var dlg = document.getElementById(id);
72 if (!dlg) return;
73 closeAllDropdowns();
74 dlg.classList.add('open');
75 dlg.setAttribute('aria-hidden', 'false');
76 var firstInput = dlg.querySelector('input:not([type="hidden"]), button[data-autofocus]');
77 if (firstInput) {
78 setTimeout(function () { firstInput.focus(); }, 20);
79 }
80 }
81 window.PolarOpenDialog = openDialog;
82
83 function closeDialog(dlg) {
84 if (!dlg) return;
85 dlg.classList.remove('open');
86 dlg.setAttribute('aria-hidden', 'true');
87 }
88
89 // Command Palette logic
90 var paletteIndex = 0;
91
92 function getVisiblePaletteItems() {
93 var list = document.querySelectorAll('#command-palette [data-palette-item]');
94 var visible = [];
95 list.forEach(function (item) {
96 if (item.style.display !== 'none') visible.push(item);
97 });
98 return visible;
99 }
100
101 function updatePaletteSelection() {
102 var items = getVisiblePaletteItems();
103 if (paletteIndex >= items.length) paletteIndex = 0;
104 if (paletteIndex < 0) paletteIndex = Math.max(0, items.length - 1);
105 items.forEach(function (el, idx) {
106 el.classList.toggle('selected', idx === paletteIndex);
107 if (idx === paletteIndex) {
108 el.scrollIntoView({ block: 'nearest' });
109 }
110 });
111 }
112
113 function filterPalette(query) {
114 var q = (query || '').trim().toLowerCase();
115 var allItems = document.querySelectorAll('#command-palette [data-palette-item]');
116 allItems.forEach(function (item) {
117 var text = (item.getAttribute('data-palette-keywords') || item.textContent || '').toLowerCase();
118 var show = q === '' || text.indexOf(q) !== -1;
119 item.style.display = show ? '' : 'none';
120 });
121 document.querySelectorAll('#command-palette [data-palette-group]').forEach(function (group) {
122 var hasVisible = false;
123 group.querySelectorAll('[data-palette-item]').forEach(function (it) {
124 if (it.style.display !== 'none') hasVisible = true;
125 });
126 group.style.display = hasVisible ? '' : 'none';
127 });
128 paletteIndex = 0;
129 updatePaletteSelection();
130 }
131
132 function openPalette(initialQuery) {
133 var palette = document.getElementById('command-palette');
134 if (!palette) return;
135 closeAllDropdowns();
136 palette.classList.add('open');
137 palette.setAttribute('aria-hidden', 'false');
138 var input = document.getElementById('command-palette-input');
139 if (input) {
140 input.value = initialQuery || '';
141 filterPalette(input.value);
142 setTimeout(function () {
143 input.focus();
144 if (initialQuery) input.select();
145 }, 20);
146 }
147 }
148 window.PolarOpenPalette = openPalette;
149
150 function activatePaletteItem(item) {
151 if (!item) return;
152 var palette = document.getElementById('command-palette');
153 var href = item.getAttribute('data-palette-href');
154 var theme = item.getAttribute('data-palette-theme');
155 var action = item.getAttribute('data-palette-action');
156 if (palette) closeDialog(palette);
157
158 if (theme) {
159 setTheme(theme, false);
160 } else if (action === 'shortcuts') {
161 openDialog('shortcuts-dialog');
162 } else if (action === 'toggle-sidebar') {
163 toggleSidebar();
164 } else if (href) {
165 window.location.href = href;
166 }
167 }
168
169 document.addEventListener('click', function (e) {
170 // Toggle sidebar
171 var sbBtn = e.target.closest('[data-toggle-sidebar]');
172 if (sbBtn) {
173 e.preventDefault();
174 toggleSidebar();
175 return;
176 }
177
178 // Dropdown trigger
179 var trigger = e.target.closest('[data-dropdown-trigger]');
180 if (trigger) {
181 e.stopPropagation();
182 var dropdown = trigger.closest('[data-dropdown]');
183 var menu = dropdown ? dropdown.querySelector('[data-dropdown-menu]') : null;
184 var wasOpen = menu && menu.classList.contains('open');
185 closeAllDropdowns();
186 if (menu && !wasOpen) {
187 menu.classList.add('open');
188 trigger.setAttribute('aria-expanded', 'true');
189 }
190 return;
191 }
192
193 // Theme button
194 var themeBtn = e.target.closest('[data-set-theme]');
195 if (themeBtn) {
196 e.preventDefault();
197 setTheme(themeBtn.getAttribute('data-set-theme'), false);
198 closeAllDropdowns();
199 return;
200 }
201
202 // Open Command Palette
203 var paletteTrigger = e.target.closest('[data-open-palette]');
204 if (paletteTrigger) {
205 e.preventDefault();
206 openPalette(paletteTrigger.getAttribute('data-open-palette') || '');
207 return;
208 }
209
210 // Palette item click
211 var paletteItem = e.target.closest('[data-palette-item]');
212 if (paletteItem) {
213 e.preventDefault();
214 activatePaletteItem(paletteItem);
215 return;
216 }
217
218 // Dialog open
219 var dlgOpen = e.target.closest('[data-dialog-open]');
220 if (dlgOpen) {
221 e.preventDefault();
222 openDialog(dlgOpen.getAttribute('data-dialog-open'));
223 return;
224 }
225
226 // Dialog close
227 var dlgClose = e.target.closest('[data-dialog-close]');
228 if (dlgClose) {
229 e.preventDefault();
230 closeDialog(dlgClose.closest('.backdrop'));
231 return;
232 }
233
234 // Backdrop click
235 if (e.target.classList.contains('backdrop')) {
236 closeDialog(e.target);
237 return;
238 }
239
240 // Mobile drawer
241 var drawerOpen = e.target.closest('[data-drawer-open]');
242 if (drawerOpen) {
243 var drw = document.getElementById('mobile-drawer');
244 if (drw) drw.classList.add('open');
245 return;
246 }
247 var drawerClose = e.target.closest('[data-drawer-close]');
248 if (drawerClose || e.target.id === 'mobile-drawer') {
249 var drw2 = document.getElementById('mobile-drawer');
250 if (drw2) drw2.classList.remove('open');
251 return;
252 }
253
254 // Copy to clipboard
255 var copyBtn = e.target.closest('[data-copy]');
256 if (copyBtn) {
257 e.preventDefault();
258 var text = copyBtn.getAttribute('data-copy');
259 var copiedMsg = document.documentElement.getAttribute('data-copied-text') || 'Copied';
260 if (navigator.clipboard && navigator.clipboard.writeText) {
261 navigator.clipboard.writeText(text).then(function () {
262 showToast(copiedMsg);
263 });
264 } else {
265 var ta = document.createElement('textarea');
266 ta.value = text;
267 document.body.appendChild(ta);
268 ta.select();
269 document.execCommand('copy');
270 document.body.removeChild(ta);
271 showToast(copiedMsg);
272 }
273 return;
274 }
275
276 if (!e.target.closest('[data-dropdown-menu]')) {
277 closeAllDropdowns();
278 }
279 });
280
281 // Input handlers (palette filter + delete repo confirmation match)
282 document.addEventListener('input', function (e) {
283 if (e.target.id === 'command-palette-input') {
284 filterPalette(e.target.value);
285 return;
286 }
287
288 var matchExpected = e.target.getAttribute('data-confirm-input');
289 if (matchExpected !== null) {
290 var btnId = e.target.getAttribute('data-confirm-button');
291 var btn = btnId ? document.getElementById(btnId) : null;
292 if (btn) {
293 btn.disabled = e.target.value.trim() !== matchExpected;
294 }
295 }
296
297 if (e.target.hasAttribute('data-file-filter')) {
298 var q = e.target.value.trim().toLowerCase();
299 document.querySelectorAll('[data-tree-row]').forEach(function (row) {
300 var name = (row.getAttribute('data-tree-name') || '').toLowerCase();
301 row.style.display = !q || name.indexOf(q) !== -1 ? '' : 'none';
302 });
303 }
304 });
305
306 // Keyboard navigation & chords
307 var lastChordKey = '';
308 var lastChordTime = 0;
309
310 document.addEventListener('keydown', function (e) {
311 var palette = document.getElementById('command-palette');
312 var isPaletteOpen = palette && palette.classList.contains('open');
313
314 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
315 e.preventDefault();
316 if (isPaletteOpen) {
317 closeDialog(palette);
318 } else {
319 openPalette('');
320 }
321 return;
322 }
323
324 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') {
325 e.preventDefault();
326 toggleSidebar();
327 return;
328 }
329
330 if (e.key === 'Escape') {
331 closeAllDropdowns();
332 document.querySelectorAll('.backdrop.open').forEach(function (b) {
333 closeDialog(b);
334 });
335 var drw = document.getElementById('mobile-drawer');
336 if (drw) drw.classList.remove('open');
337 return;
338 }
339
340 if (isPaletteOpen) {
341 var items = getVisiblePaletteItems();
342 if (e.key === 'ArrowDown') {
343 e.preventDefault();
344 paletteIndex = (paletteIndex + 1) % Math.max(1, items.length);
345 updatePaletteSelection();
346 } else if (e.key === 'ArrowUp') {
347 e.preventDefault();
348 paletteIndex = (paletteIndex - 1 + Math.max(1, items.length)) % Math.max(1, items.length);
349 updatePaletteSelection();
350 } else if (e.key === 'Enter') {
351 e.preventDefault();
352 if (items[paletteIndex]) activatePaletteItem(items[paletteIndex]);
353 }
354 return;
355 }
356
357 var tag = e.target && e.target.tagName ? e.target.tagName.toUpperCase() : '';
358 if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target && e.target.isContentEditable)) {
359 return;
360 }
361 if (e.ctrlKey || e.metaKey || e.altKey) return;
362
363 var now = Date.now();
364 if (lastChordKey === 'g' && now - lastChordTime < 900) {
365 lastChordKey = '';
366 if (e.key.toLowerCase() === 'h') {
367 e.preventDefault();
368 window.location.href = '/';
369 return;
370 }
371 if (e.key.toLowerCase() === 'r') {
372 e.preventDefault();
373 window.location.href = '/explore';
374 return;
375 }
376 }
377
378 if (e.key.toLowerCase() === 'g') {
379 lastChordKey = 'g';
380 lastChordTime = now;
381 return;
382 }
383
384 if (e.key === '/') {
385 e.preventDefault();
386 var searchInput = document.querySelector('[data-search-input]');
387 if (searchInput) {
388 searchInput.focus();
389 } else {
390 openPalette('');
391 }
392 return;
393 }
394
395 if (e.key === '?') {
396 e.preventDefault();
397 openDialog('shortcuts-dialog');
398 return;
399 }
400
401 if (e.key.toLowerCase() === 't') {
402 e.preventDefault();
403 var fileFilter = document.querySelector('[data-file-filter]');
404 if (fileFilter) {
405 fileFilter.focus();
406 } else {
407 openPalette('file:');
408 }
409 }
410 });
411
412 // Avatar file picker preview & client-side 256x256 square crop/resize
413 document.addEventListener('change', function (e) {
414 if (e.target && e.target.id === 'avatar-file-input') {
415 var file = e.target.files && e.target.files[0];
416 if (!file) return;
417 var reader = new FileReader();
418 reader.onload = function (ev) {
419 var img = new Image();
420 img.onload = function () {
421 var size = 256;
422 var canvas = document.createElement('canvas');
423 canvas.width = size;
424 canvas.height = size;
425 var ctx = canvas.getContext('2d');
426 var minSide = Math.min(img.width, img.height);
427 var sx = (img.width - minSide) / 2;
428 var sy = (img.height - minSide) / 2;
429 ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, size, size);
430 var dataUrl = canvas.toDataURL('image/webp', 0.88);
431 if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) {
432 dataUrl = canvas.toDataURL('image/jpeg', 0.88);
433 }
434 var hidden = document.getElementById('avatar-data-input');
435 var rmHidden = document.getElementById('remove-avatar-input');
436 if (hidden) hidden.value = dataUrl;
437 if (rmHidden) rmHidden.value = 'false';
438 var previewWrap = document.getElementById('avatar-preview-wrap');
439 if (previewWrap) {
440 previewWrap.innerHTML = '<span class="avatar avatar-xl"><img src="' + dataUrl + '" alt="Avatar" class="avatar-img"></span>';
441 }
442 };
443 img.src = ev.target.result;
444 };
445 reader.readAsDataURL(file);
446 }
447 });
448
449 document.addEventListener('click', function (e) {
450 var rmBtn = e.target.closest('#remove-avatar-btn');
451 if (rmBtn) {
452 e.preventDefault();
453 var rmHidden = document.getElementById('remove-avatar-input');
454 var hidden = document.getElementById('avatar-data-input');
455 var fileInput = document.getElementById('avatar-file-input');
456 if (rmHidden) rmHidden.value = 'true';
457 if (hidden) hidden.value = '';
458 if (fileInput) fileInput.value = '';
459 var previewWrap = document.getElementById('avatar-preview-wrap');
460 if (previewWrap) {
461 previewWrap.innerHTML = '<span class="avatar avatar-xl">?</span>';
462 }
463 rmBtn.style.display = 'none';
464 }
465 });
466
467 // Repo header: the description stays on the title line and expands onto its
468 // own line when clicked. The avatar grows to match the name + description
469 // block instead of staying centered.
470 var repoAvatarTimer = null;
471
472 function sizeRepoAvatars() {
473 var changed = false;
474 document.querySelectorAll('.repo-banner').forEach(function (banner) {
475 var avatar = banner.querySelector('.repo-avatar');
476 var block = banner.querySelector('.repo-head-block');
477 if (!avatar || !block) return;
478 var h = Math.round(block.getBoundingClientRect().height);
479 if (h <= 0) return;
480 var size = h + 'px';
481 if (avatar.style.width === size) return;
482 avatar.style.width = size;
483 avatar.style.height = size;
484 avatar.style.fontSize = Math.min(22, Math.max(9, Math.round(h * 0.4))) + 'px';
485 changed = true;
486 });
487 return changed;
488 }
489
490 function updateRepoDescs() {
491 document.querySelectorAll('[data-repo-desc]').forEach(function (el) {
492 var banner = el.closest('.repo-banner');
493 if (banner && banner.classList.contains('desc-open')) {
494 el.classList.add('truncated');
495 return;
496 }
497 el.classList.toggle('truncated', el.scrollWidth > el.clientWidth + 1);
498 });
499 if (sizeRepoAvatars()) {
500 // Re-measure once the width transition settles: the final avatar width
501 // changes the room left for the description.
502 window.clearTimeout(repoAvatarTimer);
503 repoAvatarTimer = window.setTimeout(sizeRepoAvatars, 300);
504 }
505 }
506
507 document.addEventListener('click', function (e) {
508 var desc = e.target.closest('[data-repo-desc]');
509 if (!desc || !desc.classList.contains('truncated')) return;
510 var banner = desc.closest('.repo-banner');
511 if (!banner) return;
512 banner.classList.toggle('desc-open');
513 updateRepoDescs();
514 });
515
516 window.addEventListener('resize', updateRepoDescs);
517 document.addEventListener('htmx:afterSwap', updateRepoDescs);
518 updateRepoDescs();
519 })();