rin
Публичный Open-source lightweight git hosting.

feat(ui): add custom context menu and navigation shortcuts

R rin · 2026-10-17 07:47 UTC
5 файлов изменено +1098 -74
assets/input.css
+179 -0
... ... @@ -1136,6 +1136,185 @@ kbd, .kbd {
1136 1136 color: hsl(var(--accent-foreground));
1137 1137 }
1138 1138
1139 +/* Custom context menu */
1140 +@keyframes contextMenuIn {
1141 + from {
1142 + opacity: 0;
1143 + transform: translateY(-3px) scale(0.98);
1144 + }
1145 + to {
1146 + opacity: 1;
1147 + transform: translateY(0) scale(1);
1148 + }
1149 +}
1150 +.context-menu {
1151 + position: fixed;
1152 + z-index: 120;
1153 + width: max-content;
1154 + min-width: 15rem;
1155 + max-width: min(20rem, calc(100vw - 1rem));
1156 + max-height: min(72vh, 36rem);
1157 + overflow-y: auto;
1158 + overscroll-behavior: contain;
1159 + padding: 0.28rem;
1160 + background-color: hsl(var(--popover));
1161 + color: hsl(var(--popover-foreground));
1162 + border: 1px solid hsl(var(--border));
1163 + border-radius: var(--radius);
1164 + box-shadow: 0 18px 44px -12px rgba(0, 0, 0, 0.42), 0 2px 8px -2px rgba(0, 0, 0, 0.18);
1165 + opacity: 0;
1166 + visibility: hidden;
1167 + pointer-events: none;
1168 + transform: translateY(-3px) scale(0.98);
1169 + transform-origin: top left;
1170 + transition: opacity 110ms ease, transform 130ms cubic-bezier(0.16, 1, 0.3, 1), visibility 110ms ease;
1171 +}
1172 +.context-menu.open {
1173 + opacity: 1;
1174 + visibility: visible;
1175 + pointer-events: auto;
1176 + transform: translateY(0) scale(1);
1177 + animation: contextMenuIn 130ms cubic-bezier(0.16, 1, 0.3, 1);
1178 +}
1179 +body[data-repo-path] .context-menu {
1180 + max-height: min(80vh, 52rem);
1181 +}
1182 +.context-menu-section[hidden],
1183 +.context-menu-item[hidden] {
1184 + display: none;
1185 +}
1186 +.context-menu-section + .context-menu-section {
1187 + margin-top: 0.24rem;
1188 + padding-top: 0.24rem;
1189 + border-top: 1px solid hsl(var(--border));
1190 +}
1191 +.context-menu-label {
1192 + display: flex;
1193 + align-items: center;
1194 + gap: 0.5rem;
1195 + padding: 0.22rem 0.5rem 0.14rem;
1196 + font-size: 0.64rem;
1197 + font-weight: 600;
1198 + letter-spacing: 0.055em;
1199 + text-transform: uppercase;
1200 + color: hsl(var(--muted-foreground));
1201 +}
1202 +.context-menu-meta {
1203 + margin-left: auto;
1204 + max-width: 9.5rem;
1205 + overflow: hidden;
1206 + text-overflow: ellipsis;
1207 + white-space: nowrap;
1208 + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
1209 + font-size: 0.6rem;
1210 + letter-spacing: 0;
1211 + text-transform: none;
1212 + opacity: 0.85;
1213 +}
1214 +.context-menu-item {
1215 + display: flex;
1216 + width: 100%;
1217 + min-height: 1.95rem;
1218 + align-items: center;
1219 + gap: 0.55rem;
1220 + padding: 0.33rem 0.5rem;
1221 + border: 0;
1222 + border-radius: calc(var(--radius) - 2px);
1223 + background: transparent;
1224 + color: hsl(var(--popover-foreground));
1225 + font: inherit;
1226 + font-size: 0.8rem;
1227 + line-height: 1.25;
1228 + text-align: left;
1229 + text-decoration: none;
1230 + cursor: pointer;
1231 + user-select: none;
1232 + transition: background-color 90ms ease, color 90ms ease;
1233 +}
1234 +.context-menu-item .icon {
1235 + flex: none;
1236 + color: hsl(var(--muted-foreground));
1237 + transition: color 90ms ease;
1238 +}
1239 +.context-menu-item > span:not(.context-menu-check):not(.context-menu-kbd) {
1240 + min-width: 0;
1241 + overflow: hidden;
1242 + text-overflow: ellipsis;
1243 + white-space: nowrap;
1244 +}
1245 +.context-menu-item:hover,
1246 +.context-menu-item:focus-visible,
1247 +.context-menu-item.is-active {
1248 + background-color: hsl(var(--accent));
1249 + color: hsl(var(--accent-foreground));
1250 + outline: none;
1251 +}
1252 +.context-menu-item:hover .icon,
1253 +.context-menu-item:focus-visible .icon,
1254 +.context-menu-item.is-active .icon {
1255 + color: currentColor;
1256 +}
1257 +.context-menu-kbd {
1258 + margin-left: auto;
1259 + flex: none;
1260 + min-width: auto;
1261 + height: 1.05rem;
1262 + padding: 0 0.24rem;
1263 + font-size: 0.6rem;
1264 + color: hsl(var(--muted-foreground));
1265 + background-color: hsl(var(--muted));
1266 + border: 1px solid hsl(var(--border));
1267 + border-radius: 0.22rem;
1268 +}
1269 +.context-menu-check {
1270 + display: inline-flex;
1271 + margin-left: auto;
1272 + flex: none;
1273 + opacity: 0;
1274 + color: hsl(var(--foreground));
1275 +}
1276 +.context-menu-item.is-current .context-menu-check {
1277 + opacity: 1;
1278 +}
1279 +
1280 +/* Shortcuts dialog */
1281 +.shortcuts-list {
1282 + display: flex;
1283 + max-height: min(70vh, 44rem);
1284 + flex-direction: column;
1285 + padding-top: 0.45rem;
1286 + padding-bottom: 0.55rem;
1287 + overflow-y: auto;
1288 + overscroll-behavior: contain;
1289 +}
1290 +.shortcuts-group-label {
1291 + padding: 0.65rem 0 0.2rem;
1292 + font-size: 0.64rem;
1293 + font-weight: 600;
1294 + letter-spacing: 0.055em;
1295 + text-transform: uppercase;
1296 + color: hsl(var(--muted-foreground));
1297 +}
1298 +.shortcuts-group-label:first-child {
1299 + padding-top: 0.15rem;
1300 +}
1301 +.shortcut-row {
1302 + display: flex;
1303 + min-height: 2rem;
1304 + align-items: center;
1305 + justify-content: space-between;
1306 + gap: 1rem;
1307 + padding: 0.32rem 0;
1308 + border-bottom: 1px solid hsl(var(--border));
1309 + font-size: 0.82rem;
1310 +}
1311 +.shortcut-keys {
1312 + display: flex;
1313 + flex: none;
1314 + align-items: center;
1315 + gap: 0.2rem;
1316 +}
1317 +
1139 1318 /* Toast */
1140 1319 @keyframes toastIn {
1141 1320 from {
internal/i18n/i18n.go
+92 -22
... ... @@ -36,6 +36,7 @@ var translations = map[Lang]map[string]string{
36 36 "nav.lang": "Язык",
37 37 "common.copy": "Копировать",
38 38 "common.copied": "Скопировано",
39 + "common.copy_failed": "Не удалось скопировать",
39 40 "common.cancel": "Отмена",
40 41 "common.save": "Сохранить",
41 42 "common.delete": "Удалить",
... ... @@ -327,17 +328,51 @@ var translations = map[Lang]map[string]string{
327 328 "admin.role.user": "Участник",
328 329
329 330 // Командная палитра и хоткеи
330 - "palette.placeholder": "Поиск репозитория, файла или команды...",
331 - "palette.group.nav": "Навигация",
332 - "palette.group.themes": "Тема оформления",
333 - "palette.group.repos": "Репозитории",
334 - "shortcuts.title": "Горячие клавиши",
335 - "shortcuts.palette": "Командная палитра",
336 - "shortcuts.search": "Фокус на поиск",
337 - "shortcuts.home": "На главную",
338 - "shortcuts.files": "Фильтр файлов",
339 - "shortcuts.sidebar": "Скрыть / показать сайдбар",
340 - "nav.toggle_sidebar": "Сайдбар (Ctrl+B)",
331 + "palette.placeholder": "Поиск репозитория, файла или команды...",
332 + "palette.group.nav": "Навигация",
333 + "palette.group.themes": "Тема оформления",
334 + "palette.group.repos": "Репозитории",
335 + "shortcuts.title": "Горячие клавиши",
336 + "shortcuts.palette": "Командная палитра",
337 + "shortcuts.search": "Фокус на поиск",
338 + "shortcuts.home": "На главную",
339 + "shortcuts.files": "Фильтр файлов",
340 + "shortcuts.sidebar": "Скрыть / показать сайдбар",
341 + "shortcuts.shortcuts": "Это окно шорткатов",
342 + "shortcuts.context_menu": "Открыть контекстное меню",
343 + "shortcuts.theme": "Переключить тему",
344 + "shortcuts.copy_url": "Копировать адрес страницы",
345 + "shortcuts.close": "Закрыть меню или диалог",
346 + "shortcuts.profile": "Мой профиль",
347 + "shortcuts.new_repo": "Новый репозиторий",
348 + "shortcuts.unavailable": "Этот шорткат здесь недоступен",
349 + "shortcuts.group.general": "Общее",
350 + "shortcuts.group.navigation": "Навигация",
351 + "shortcuts.group.repo": "Репозиторий",
352 + "nav.toggle_sidebar": "Сайдбар (Ctrl+B)",
353 +
354 + // Контекстное меню
355 + "contextmenu.title": "Контекстное меню",
356 + "contextmenu.selection": "Выделение",
357 + "contextmenu.copy_selection": "Копировать выделенное",
358 + "contextmenu.search_selection": "Искать выделенное",
359 + "contextmenu.link": "Ссылка",
360 + "contextmenu.open_link": "Открыть в новой вкладке",
361 + "contextmenu.copy_link": "Копировать адрес ссылки",
362 + "contextmenu.copy_link_text": "Копировать текст ссылки",
363 + "contextmenu.image": "Изображение",
364 + "contextmenu.open_image": "Открыть изображение",
365 + "contextmenu.copy_image": "Копировать адрес изображения",
366 + "contextmenu.save_image": "Сохранить изображение",
367 + "contextmenu.repository": "Репозиторий",
368 + "contextmenu.repo_actions": "Действия",
369 + "contextmenu.page": "Страница",
370 + "contextmenu.back": "Назад",
371 + "contextmenu.forward": "Вперёд",
372 + "contextmenu.reload": "Обновить",
373 + "contextmenu.copy_url": "Копировать адрес страницы",
374 + "contextmenu.appearance": "Оформление",
375 + "contextmenu.app": "Приложение",
341 376
342 377 // Флеш-сообщения
343 378 "flash.welcome": "Добро пожаловать в Polar, @%s!",
... ... @@ -618,6 +653,7 @@ var translations = map[Lang]map[string]string{
618 653 "nav.lang": "Language",
619 654 "common.copy": "Copy",
620 655 "common.copied": "Copied",
656 + "common.copy_failed": "Could not copy",
621 657 "common.cancel": "Cancel",
622 658 "common.save": "Save",
623 659 "common.delete": "Delete",
... ... @@ -910,17 +946,51 @@ var translations = map[Lang]map[string]string{
910 946 "admin.role.user": "User",
911 947
912 948 // Command Palette & Shortcuts
913 - "palette.placeholder": "Search repositories, files, or commands...",
914 - "palette.group.nav": "Navigation",
915 - "palette.group.themes": "Theme",
916 - "palette.group.repos": "Repositories",
917 - "shortcuts.title": "Keyboard shortcuts",
918 - "shortcuts.palette": "Command palette",
919 - "shortcuts.search": "Focus search",
920 - "shortcuts.home": "Go home",
921 - "shortcuts.files": "Filter files",
922 - "shortcuts.sidebar": "Toggle sidebar",
923 - "nav.toggle_sidebar": "Sidebar (Ctrl+B)",
949 + "palette.placeholder": "Search repositories, files, or commands...",
950 + "palette.group.nav": "Navigation",
951 + "palette.group.themes": "Theme",
952 + "palette.group.repos": "Repositories",
953 + "shortcuts.title": "Keyboard shortcuts",
954 + "shortcuts.palette": "Command palette",
955 + "shortcuts.search": "Focus search",
956 + "shortcuts.home": "Go home",
957 + "shortcuts.files": "Filter files",
958 + "shortcuts.sidebar": "Toggle sidebar",
959 + "shortcuts.shortcuts": "Show this dialog",
960 + "shortcuts.context_menu": "Open context menu",
961 + "shortcuts.theme": "Cycle theme",
962 + "shortcuts.copy_url": "Copy page URL",
963 + "shortcuts.close": "Close menu or dialog",
964 + "shortcuts.profile": "Your profile",
965 + "shortcuts.new_repo": "New repository",
966 + "shortcuts.unavailable": "This shortcut is unavailable here",
967 + "shortcuts.group.general": "General",
968 + "shortcuts.group.navigation": "Navigation",
969 + "shortcuts.group.repo": "Repository",
970 + "nav.toggle_sidebar": "Sidebar (Ctrl+B)",
971 +
972 + // Context menu
973 + "contextmenu.title": "Context menu",
974 + "contextmenu.selection": "Selection",
975 + "contextmenu.copy_selection": "Copy selection",
976 + "contextmenu.search_selection": "Search selection",
977 + "contextmenu.link": "Link",
978 + "contextmenu.open_link": "Open in new tab",
979 + "contextmenu.copy_link": "Copy link address",
980 + "contextmenu.copy_link_text": "Copy link text",
981 + "contextmenu.image": "Image",
982 + "contextmenu.open_image": "Open image in new tab",
983 + "contextmenu.copy_image": "Copy image address",
984 + "contextmenu.save_image": "Save image",
985 + "contextmenu.repository": "Repository",
986 + "contextmenu.repo_actions": "Actions",
987 + "contextmenu.page": "Page",
988 + "contextmenu.back": "Back",
989 + "contextmenu.forward": "Forward",
990 + "contextmenu.reload": "Reload",
991 + "contextmenu.copy_url": "Copy page URL",
992 + "contextmenu.appearance": "Appearance",
993 + "contextmenu.app": "Application",
924 994
925 995 // Flash messages
926 996 "flash.welcome": "Welcome to Polar, @%s!",
internal/web/static/css/app.css
+179 -0
... ... @@ -1136,6 +1136,185 @@ kbd, .kbd {
1136 1136 color: hsl(var(--accent-foreground));
1137 1137 }
1138 1138
1139 +/* Custom context menu */
1140 +@keyframes contextMenuIn {
1141 + from {
1142 + opacity: 0;
1143 + transform: translateY(-3px) scale(0.98);
1144 + }
1145 + to {
1146 + opacity: 1;
1147 + transform: translateY(0) scale(1);
1148 + }
1149 +}
1150 +.context-menu {
1151 + position: fixed;
1152 + z-index: 120;
1153 + width: max-content;
1154 + min-width: 15rem;
1155 + max-width: min(20rem, calc(100vw - 1rem));
1156 + max-height: min(72vh, 36rem);
1157 + overflow-y: auto;
1158 + overscroll-behavior: contain;
1159 + padding: 0.28rem;
1160 + background-color: hsl(var(--popover));
1161 + color: hsl(var(--popover-foreground));
1162 + border: 1px solid hsl(var(--border));
1163 + border-radius: var(--radius);
1164 + box-shadow: 0 18px 44px -12px rgba(0, 0, 0, 0.42), 0 2px 8px -2px rgba(0, 0, 0, 0.18);
1165 + opacity: 0;
1166 + visibility: hidden;
1167 + pointer-events: none;
1168 + transform: translateY(-3px) scale(0.98);
1169 + transform-origin: top left;
1170 + transition: opacity 110ms ease, transform 130ms cubic-bezier(0.16, 1, 0.3, 1), visibility 110ms ease;
1171 +}
1172 +.context-menu.open {
1173 + opacity: 1;
1174 + visibility: visible;
1175 + pointer-events: auto;
1176 + transform: translateY(0) scale(1);
1177 + animation: contextMenuIn 130ms cubic-bezier(0.16, 1, 0.3, 1);
1178 +}
1179 +body[data-repo-path] .context-menu {
1180 + max-height: min(80vh, 52rem);
1181 +}
1182 +.context-menu-section[hidden],
1183 +.context-menu-item[hidden] {
1184 + display: none;
1185 +}
1186 +.context-menu-section + .context-menu-section {
1187 + margin-top: 0.24rem;
1188 + padding-top: 0.24rem;
1189 + border-top: 1px solid hsl(var(--border));
1190 +}
1191 +.context-menu-label {
1192 + display: flex;
1193 + align-items: center;
1194 + gap: 0.5rem;
1195 + padding: 0.22rem 0.5rem 0.14rem;
1196 + font-size: 0.64rem;
1197 + font-weight: 600;
1198 + letter-spacing: 0.055em;
1199 + text-transform: uppercase;
1200 + color: hsl(var(--muted-foreground));
1201 +}
1202 +.context-menu-meta {
1203 + margin-left: auto;
1204 + max-width: 9.5rem;
1205 + overflow: hidden;
1206 + text-overflow: ellipsis;
1207 + white-space: nowrap;
1208 + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
1209 + font-size: 0.6rem;
1210 + letter-spacing: 0;
1211 + text-transform: none;
1212 + opacity: 0.85;
1213 +}
1214 +.context-menu-item {
1215 + display: flex;
1216 + width: 100%;
1217 + min-height: 1.95rem;
1218 + align-items: center;
1219 + gap: 0.55rem;
1220 + padding: 0.33rem 0.5rem;
1221 + border: 0;
1222 + border-radius: calc(var(--radius) - 2px);
1223 + background: transparent;
1224 + color: hsl(var(--popover-foreground));
1225 + font: inherit;
1226 + font-size: 0.8rem;
1227 + line-height: 1.25;
1228 + text-align: left;
1229 + text-decoration: none;
1230 + cursor: pointer;
1231 + user-select: none;
1232 + transition: background-color 90ms ease, color 90ms ease;
1233 +}
1234 +.context-menu-item .icon {
1235 + flex: none;
1236 + color: hsl(var(--muted-foreground));
1237 + transition: color 90ms ease;
1238 +}
1239 +.context-menu-item > span:not(.context-menu-check):not(.context-menu-kbd) {
1240 + min-width: 0;
1241 + overflow: hidden;
1242 + text-overflow: ellipsis;
1243 + white-space: nowrap;
1244 +}
1245 +.context-menu-item:hover,
1246 +.context-menu-item:focus-visible,
1247 +.context-menu-item.is-active {
1248 + background-color: hsl(var(--accent));
1249 + color: hsl(var(--accent-foreground));
1250 + outline: none;
1251 +}
1252 +.context-menu-item:hover .icon,
1253 +.context-menu-item:focus-visible .icon,
1254 +.context-menu-item.is-active .icon {
1255 + color: currentColor;
1256 +}
1257 +.context-menu-kbd {
1258 + margin-left: auto;
1259 + flex: none;
1260 + min-width: auto;
1261 + height: 1.05rem;
1262 + padding: 0 0.24rem;
1263 + font-size: 0.6rem;
1264 + color: hsl(var(--muted-foreground));
1265 + background-color: hsl(var(--muted));
1266 + border: 1px solid hsl(var(--border));
1267 + border-radius: 0.22rem;
1268 +}
1269 +.context-menu-check {
1270 + display: inline-flex;
1271 + margin-left: auto;
1272 + flex: none;
1273 + opacity: 0;
1274 + color: hsl(var(--foreground));
1275 +}
1276 +.context-menu-item.is-current .context-menu-check {
1277 + opacity: 1;
1278 +}
1279 +
1280 +/* Shortcuts dialog */
1281 +.shortcuts-list {
1282 + display: flex;
1283 + max-height: min(70vh, 44rem);
1284 + flex-direction: column;
1285 + padding-top: 0.45rem;
1286 + padding-bottom: 0.55rem;
1287 + overflow-y: auto;
1288 + overscroll-behavior: contain;
1289 +}
1290 +.shortcuts-group-label {
1291 + padding: 0.65rem 0 0.2rem;
1292 + font-size: 0.64rem;
1293 + font-weight: 600;
1294 + letter-spacing: 0.055em;
1295 + text-transform: uppercase;
1296 + color: hsl(var(--muted-foreground));
1297 +}
1298 +.shortcuts-group-label:first-child {
1299 + padding-top: 0.15rem;
1300 +}
1301 +.shortcut-row {
1302 + display: flex;
1303 + min-height: 2rem;
1304 + align-items: center;
1305 + justify-content: space-between;
1306 + gap: 1rem;
1307 + padding: 0.32rem 0;
1308 + border-bottom: 1px solid hsl(var(--border));
1309 + font-size: 0.82rem;
1310 +}
1311 +.shortcut-keys {
1312 + display: flex;
1313 + flex: none;
1314 + align-items: center;
1315 + gap: 0.2rem;
1316 +}
1317 +
1139 1318 /* Toast */
1140 1319 @keyframes toastIn {
1141 1320 from {
internal/web/static/js/app.js
+463 -26
... ... @@ -15,6 +15,46 @@
15 15 }
16 16 window.PolarToast = showToast;
17 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 +
18 58 function setTheme(theme, silent) {
19 59 var allowed = ['light', 'dark', 'system'];
20 60 if (allowed.indexOf(theme) === -1) theme = 'system';
... ... @@ -29,6 +69,7 @@
29 69 el.classList.toggle('active', isCurrent);
30 70 el.setAttribute('aria-checked', isCurrent ? 'true' : 'false');
31 71 });
72 + updateContextMenuThemeState();
32 73 }
33 74 window.PolarSetTheme = setTheme;
34 75
... ... @@ -67,10 +108,354 @@
67 108 });
68 109 }
69 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 +
70 454 function openDialog(id) {
71 455 var dlg = document.getElementById(id);
72 456 if (!dlg) return;
73 457 closeAllDropdowns();
458 + closeContextMenu();
74 459 dlg.classList.add('open');
75 460 dlg.setAttribute('aria-hidden', 'false');
76 461 var firstInput = dlg.querySelector('input:not([type="hidden"]), button[data-autofocus]');
... ... @@ -133,6 +518,7 @@
133 518 var palette = document.getElementById('command-palette');
134 519 if (!palette) return;
135 520 closeAllDropdowns();
521 + closeContextMenu();
136 522 palette.classList.add('open');
137 523 palette.setAttribute('aria-hidden', 'false');
138 524 var input = document.getElementById('command-palette-input');
... ... @@ -167,6 +553,21 @@
167 553 }
168 554
169 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 +
170 571 // Toggle sidebar
171 572 var sbBtn = e.target.closest('[data-toggle-sidebar]');
172 573 if (sbBtn) {
... ... @@ -255,21 +656,7 @@
255 656 var copyBtn = e.target.closest('[data-copy]');
256 657 if (copyBtn) {
257 658 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 - }
659 + copyText(copyBtn.getAttribute('data-copy'));
273 660 return;
274 661 }
275 662
... ... @@ -310,6 +697,47 @@
310 697 document.addEventListener('keydown', function (e) {
311 698 var palette = document.getElementById('command-palette');
312 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 + }
313 741
314 742 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
315 743 e.preventDefault();
... ... @@ -328,6 +756,7 @@
328 756 }
329 757
330 758 if (e.key === 'Escape') {
759 + closeContextMenu();
331 760 closeAllDropdowns();
332 761 document.querySelectorAll('.backdrop.open').forEach(function (b) {
333 762 closeDialog(b);
... ... @@ -354,33 +783,41 @@
354 783 return;
355 784 }
356 785
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)) {
786 + if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') {
787 + if (isEditableTarget(e.target)) return;
788 + e.preventDefault();
789 + openContextMenuForKeyboard();
359 790 return;
360 791 }
792 +
793 + if (isEditableTarget(e.target)) return;
361 794 if (e.ctrlKey || e.metaKey || e.altKey) return;
362 795
796 + var key = e.key.toLowerCase();
363 797 var now = Date.now();
364 798 if (lastChordKey === 'g' && now - lastChordTime < 900) {
365 799 lastChordKey = '';
366 - if (e.key.toLowerCase() === 'h') {
800 + lastChordTime = 0;
801 + if (handleNavigationChord(key)) {
367 802 e.preventDefault();
368 - window.location.href = '/';
369 - return;
370 - }
371 - if (e.key.toLowerCase() === 'r') {
372 - e.preventDefault();
373 - window.location.href = '/explore';
374 803 return;
375 804 }
805 + } else if (key !== 'g') {
806 + lastChordKey = '';
376 807 }
377 808
378 - if (e.key.toLowerCase() === 'g') {
809 + if (key === 'g') {
379 810 lastChordKey = 'g';
380 811 lastChordTime = now;
381 812 return;
382 813 }
383 814
815 + if (key === 'y') {
816 + e.preventDefault();
817 + copyText(window.location.href);
818 + return;
819 + }
820 +
384 821 if (e.key === '/') {
385 822 e.preventDefault();
386 823 var searchInput = document.querySelector('[data-search-input]');
... ... @@ -398,7 +835,7 @@
398 835 return;
399 836 }
400 837
401 - if (e.key.toLowerCase() === 't') {
838 + if (key === 't') {
402 839 e.preventDefault();
403 840 var fileFilter = document.querySelector('[data-file-filter]');
404 841 if (fileFilter) {
internal/web/views/layouts/base.html
+185 -26
... ... @@ -1,5 +1,5 @@
1 1 <!DOCTYPE html>
2 -<html lang="{{.Lang}}" data-theme="{{if .Theme}}{{.Theme}}{{else}}system{{end}}" data-copied-text="{{t .Lang "common.copied"}}">
2 +<html lang="{{.Lang}}" data-theme="{{if .Theme}}{{.Theme}}{{else}}system{{end}}" data-copied-text="{{t .Lang "common.copied"}}" data-copy-failed-text="{{t .Lang "common.copy_failed"}}" data-shortcut-unavailable="{{t .Lang "shortcuts.unavailable"}}">
3 3 <head>
4 4 <meta charset="utf-8">
5 5 <meta name="viewport" content="width=device-width, initial-scale=1">
... ... @@ -23,7 +23,7 @@
23 23 <style>{{inlineCSS}}</style>
24 24 <link rel="stylesheet" href="/static/css/app.css?v={{assetVer}}">
25 25 </head>
26 -<body>
26 +<body{{if .CurrentUser}} data-current-user="{{.CurrentUser.Username}}"{{end}}{{if .Repo}} data-repo-path="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}" data-repo-can-write="{{if .CanWrite}}true{{else}}false{{end}}" data-repo-issues="{{if .Repo.EnableIssues}}true{{else}}false{{end}}" data-repo-pulls="{{if .Repo.EnablePulls}}true{{else}}false{{end}}" data-repo-releases="{{if .Repo.EnableReleases}}true{{else}}false{{end}}" data-repo-activity="{{if .Repo.EnableActivity}}true{{else}}false{{end}}"{{end}}>
27 27 {{sprite}}
28 28 {{$pageBackground := ""}}
29 29 {{with .ProfileUser}}{{$pageBackground = imageURL .BackgroundURL}}{{end}}
... ... @@ -328,32 +328,191 @@
328 328 {{icon "close" "size-4"}}
329 329 </button>
330 330 </div>
331 - <div class="card-body flex flex-col gap-2 text-sm">
332 - <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));">
333 - <span>{{t .Lang "shortcuts.palette"}}</span>
334 - <span class="flex items-center gap-1"><kbd class="kbd">Ctrl</kbd><kbd class="kbd">K</kbd></span>
335 - </div>
336 - <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));">
337 - <span>{{t .Lang "shortcuts.sidebar"}}</span>
338 - <span class="flex items-center gap-1"><kbd class="kbd">Ctrl</kbd><kbd class="kbd">B</kbd></span>
339 - </div>
340 - <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));">
341 - <span>{{t .Lang "shortcuts.search"}}</span>
342 - <kbd class="kbd">/</kbd>
343 - </div>
344 - <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));">
345 - <span>{{t .Lang "shortcuts.home"}}</span>
346 - <span class="flex items-center gap-1"><kbd class="kbd">g</kbd><kbd class="kbd">h</kbd></span>
347 - </div>
348 - <div class="flex items-center justify-between py-2" style="border-bottom:1px solid hsl(var(--border));">
349 - <span>{{t .Lang "nav.explore"}}</span>
350 - <span class="flex items-center gap-1"><kbd class="kbd">g</kbd><kbd class="kbd">r</kbd></span>
351 - </div>
352 - <div class="flex items-center justify-between py-2">
353 - <span>{{t .Lang "shortcuts.files"}}</span>
354 - <kbd class="kbd">t</kbd>
331 + <div class="card-body shortcuts-list">
332 + <div class="shortcuts-group-label">{{t .Lang "shortcuts.group.general"}}</div>
333 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.palette"}}</span><span class="shortcut-keys"><kbd class="kbd">Ctrl</kbd><kbd class="kbd">K</kbd></span></div>
334 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.shortcuts"}}</span><kbd class="kbd">?</kbd></div>
335 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.context_menu"}}</span><span class="shortcut-keys"><kbd class="kbd">Shift</kbd><kbd class="kbd">F10</kbd></span></div>
336 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.theme"}}</span><span class="shortcut-keys"><kbd class="kbd">Ctrl</kbd><kbd class="kbd">Shift</kbd><kbd class="kbd">L</kbd></span></div>
337 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.copy_url"}}</span><kbd class="kbd">y</kbd></div>
338 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.sidebar"}}</span><span class="shortcut-keys"><kbd class="kbd">Ctrl</kbd><kbd class="kbd">B</kbd></span></div>
339 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.search"}}</span><kbd class="kbd">/</kbd></div>
340 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.close"}}</span><kbd class="kbd">Esc</kbd></div>
341 +
342 + <div class="shortcuts-group-label">{{t .Lang "shortcuts.group.navigation"}}</div>
343 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.home"}}</span><span class="shortcut-keys"><kbd class="kbd">g</kbd><kbd class="kbd">h</kbd></span></div>
344 + <div class="shortcut-row"><span>{{t .Lang "nav.explore"}}</span><span class="shortcut-keys"><kbd class="kbd">g</kbd><kbd class="kbd">r</kbd></span></div>
345 + {{if .CurrentUser}}
346 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.profile"}}</span><span class="shortcut-keys"><kbd class="kbd">g</kbd><kbd class="kbd">o</kbd></span></div>
347 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.new_repo"}}</span><span class="shortcut-keys"><kbd class="kbd">g</kbd><kbd class="kbd">n</kbd></span></div>
348 + {{end}}
349 +
350 + {{if .Repo}}
351 + <div class="shortcuts-group-label">{{t .Lang "shortcuts.group.repo"}}</div>
352 + <div class="shortcut-row"><span>{{t .Lang "repo.tab.code"}}</span><span class="shortcut-keys"><kbd class="kbd">g</kbd><kbd class="kbd">c</kbd></span></div>
353 + {{if .Repo.EnableIssues}}<div class="shortcut-row"><span>{{t .Lang "repo.tab.issues"}}</span><span class="shortcut-keys"><kbd class="kbd">g</kbd><kbd class="kbd">i</kbd></span></div>{{end}}
354 + {{if .Repo.EnablePulls}}<div class="shortcut-row"><span>{{t .Lang "repo.tab.pulls"}}</span><span class="shortcut-keys"><kbd class="kbd">g</kbd><kbd class="kbd">p</kbd></span></div>{{end}}
355 + {{if .Repo.EnableReleases}}<div class="shortcut-row"><span>{{t .Lang "repo.tab.releases"}}</span><span class="shortcut-keys"><kbd class="kbd">g</kbd><kbd class="kbd">l</kbd></span></div>{{end}}
356 + {{if .Repo.EnableActivity}}<div class="shortcut-row"><span>{{t .Lang "repo.tab.activity"}}</span><span class="shortcut-keys"><kbd class="kbd">g</kbd><kbd class="kbd">a</kbd></span></div>{{end}}
357 + <div class="shortcut-row"><span>{{t .Lang "repo.tab.branches"}}</span><span class="shortcut-keys"><kbd class="kbd">g</kbd><kbd class="kbd">b</kbd></span></div>
358 + {{if .CanWrite}}<div class="shortcut-row"><span>{{t .Lang "repo.tab.settings"}}</span><span class="shortcut-keys"><kbd class="kbd">g</kbd><kbd class="kbd">s</kbd></span></div>{{end}}
359 + <div class="shortcut-row"><span>{{t .Lang "shortcuts.files"}}</span><kbd class="kbd">t</kbd></div>
360 + {{end}}
361 + </div>
362 + </div>
363 + </div>
364 +
365 +
366 + <!-- Custom Context Menu -->
367 + <div id="context-menu" class="context-menu" role="menu" aria-label="{{t .Lang "contextmenu.title"}}" aria-hidden="true">
368 + <div class="context-menu-section" data-context-section="selection" hidden>
369 + <div class="context-menu-label">{{t .Lang "contextmenu.selection"}}</div>
370 + <button type="button" class="context-menu-item" data-context-action="copy-selection" role="menuitem">
371 + {{icon "copy" "size-3.5"}}<span>{{t .Lang "contextmenu.copy_selection"}}</span>
372 + </button>
373 + <button type="button" class="context-menu-item" data-context-action="search-selection" role="menuitem">
374 + {{icon "search" "size-3.5"}}<span>{{t .Lang "contextmenu.search_selection"}}</span>
375 + </button>
376 + </div>
377 +
378 + <div class="context-menu-section" data-context-section="link" hidden>
379 + <div class="context-menu-label">{{t .Lang "contextmenu.link"}}</div>
380 + <button type="button" class="context-menu-item" data-context-action="link-open" role="menuitem">
381 + {{icon "link" "size-3.5"}}<span>{{t .Lang "contextmenu.open_link"}}</span>
382 + </button>
383 + <button type="button" class="context-menu-item" data-context-action="link-copy" role="menuitem">
384 + {{icon "copy" "size-3.5"}}<span>{{t .Lang "contextmenu.copy_link"}}</span>
385 + </button>
386 + <button type="button" class="context-menu-item" data-context-action="link-text" role="menuitem">
387 + {{icon "file" "size-3.5"}}<span>{{t .Lang "contextmenu.copy_link_text"}}</span>
388 + </button>
389 + </div>
390 +
391 + <div class="context-menu-section" data-context-section="image" hidden>
392 + <div class="context-menu-label">{{t .Lang "contextmenu.image"}}</div>
393 + <button type="button" class="context-menu-item" data-context-action="image-open" role="menuitem">
394 + {{icon "image" "size-3.5"}}<span>{{t .Lang "contextmenu.open_image"}}</span>
395 + </button>
396 + <button type="button" class="context-menu-item" data-context-action="image-copy" role="menuitem">
397 + {{icon "copy" "size-3.5"}}<span>{{t .Lang "contextmenu.copy_image"}}</span>
398 + </button>
399 + <button type="button" class="context-menu-item" data-context-action="image-save" role="menuitem">
400 + {{icon "download" "size-3.5"}}<span>{{t .Lang "contextmenu.save_image"}}</span>
401 + </button>
402 + </div>
403 +
404 + {{if .Repo}}
405 + <div class="context-menu-section" data-context-section="repo">
406 + <div class="context-menu-label">
407 + <span>{{t .Lang "contextmenu.repository"}}</span>
408 + <span class="context-menu-meta">{{.Repo.OwnerUsername}}/{{.Repo.Name}}</span>
355 409 </div>
410 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}" role="menuitem">
411 + {{icon "code" "size-3.5"}}<span>{{t .Lang "repo.tab.code"}}</span>
412 + </a>
413 + {{if .Repo.EnableIssues}}
414 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues" role="menuitem">
415 + {{icon "issue" "size-3.5"}}<span>{{t .Lang "repo.tab.issues"}}</span>
416 + </a>
417 + {{end}}
418 + {{if .Repo.EnablePulls}}
419 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls" role="menuitem">
420 + {{icon "git-pull-request" "size-3.5"}}<span>{{t .Lang "repo.tab.pulls"}}</span>
421 + </a>
422 + {{end}}
423 + {{if .Repo.EnableReleases}}
424 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases" role="menuitem">
425 + {{icon "tag" "size-3.5"}}<span>{{t .Lang "repo.tab.releases"}}</span>
426 + </a>
427 + {{end}}
428 + {{if .Repo.EnableActivity}}
429 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/activity" role="menuitem">
430 + {{icon "activity" "size-3.5"}}<span>{{t .Lang "repo.tab.activity"}}</span>
431 + </a>
432 + {{end}}
433 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/branches" role="menuitem">
434 + {{icon "git-branch" "size-3.5"}}<span>{{t .Lang "repo.tab.branches"}}</span>
435 + </a>
436 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commits/{{.Repo.DefaultBranch}}" role="menuitem">
437 + {{icon "history" "size-3.5"}}<span>{{t .Lang "repo.tab.commits"}}</span>
438 + </a>
356 439 </div>
440 +
441 + {{if or .CanWrite (and .CurrentUser (or .Repo.EnableIssues .Repo.EnablePulls))}}
442 + <div class="context-menu-section" data-context-section="repo-actions">
443 + <div class="context-menu-label">{{t .Lang "contextmenu.repo_actions"}}</div>
444 + {{if .CurrentUser}}{{if .Repo.EnableIssues}}
445 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/new" role="menuitem">
446 + {{icon "issue" "size-3.5"}}<span>{{t .Lang "issues.new"}}</span>
447 + </a>
448 + {{end}}{{if .Repo.EnablePulls}}
449 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/new" role="menuitem">
450 + {{icon "git-pull-request" "size-3.5"}}<span>{{t .Lang "pulls.new"}}</span>
451 + </a>
452 + {{end}}{{end}}
453 + {{if .CanWrite}}
454 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-file" role="menuitem">
455 + {{icon "plus" "size-3.5"}}<span>{{t .Lang "file.new_file"}}</span>
456 + </a>
457 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-folder" role="menuitem">
458 + {{icon "folder" "size-3.5"}}<span>{{t .Lang "file.new_folder"}}</span>
459 + </a>
460 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/upload" role="menuitem">
461 + {{icon "upload" "size-3.5"}}<span>{{t .Lang "file.upload_file"}}</span>
462 + </a>
463 + <a class="context-menu-item" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/settings" role="menuitem">
464 + {{icon "settings" "size-3.5"}}<span>{{t .Lang "repo.tab.settings"}}</span>
465 + </a>
466 + {{end}}
467 + </div>
468 + {{end}}
469 + {{end}}
470 +
471 + <div class="context-menu-section" data-context-section="page">
472 + <div class="context-menu-label">{{t .Lang "contextmenu.page"}}</div>
473 + <button type="button" class="context-menu-item" data-context-action="page-back" role="menuitem">
474 + {{icon "arrow-left" "size-3.5"}}<span>{{t .Lang "contextmenu.back"}}</span>
475 + </button>
476 + <button type="button" class="context-menu-item" data-context-action="page-forward" role="menuitem">
477 + {{icon "arrow-right" "size-3.5"}}<span>{{t .Lang "contextmenu.forward"}}</span>
478 + </button>
479 + <button type="button" class="context-menu-item" data-context-action="page-reload" role="menuitem">
480 + {{icon "history" "size-3.5"}}<span>{{t .Lang "contextmenu.reload"}}</span>
481 + </button>
482 + <button type="button" class="context-menu-item" data-context-action="page-copy" role="menuitem">
483 + {{icon "copy" "size-3.5"}}<span>{{t .Lang "contextmenu.copy_url"}}</span><kbd class="context-menu-kbd">y</kbd>
484 + </button>
485 + </div>
486 +
487 + <div class="context-menu-section" data-context-section="appearance">
488 + <div class="context-menu-label">{{t .Lang "contextmenu.appearance"}}</div>
489 + <button type="button" class="context-menu-item" data-context-action="theme" data-theme-value="light" role="menuitemradio" aria-checked="false">
490 + {{icon "sun" "size-3.5"}}<span>{{t .Lang "theme.light"}}</span><span class="context-menu-check">{{icon "check" "size-3.5"}}</span>
491 + </button>
492 + <button type="button" class="context-menu-item" data-context-action="theme" data-theme-value="dark" role="menuitemradio" aria-checked="false">
493 + {{icon "moon" "size-3.5"}}<span>{{t .Lang "theme.dark"}}</span><span class="context-menu-check">{{icon "check" "size-3.5"}}</span>
494 + </button>
495 + <button type="button" class="context-menu-item" data-context-action="theme" data-theme-value="system" role="menuitemradio" aria-checked="false">
496 + {{icon "laptop" "size-3.5"}}<span>{{t .Lang "theme.system"}}</span><span class="context-menu-check">{{icon "check" "size-3.5"}}</span>
497 + </button>
498 + </div>
499 +
500 + <div class="context-menu-section" data-context-section="app">
501 + <div class="context-menu-label">{{t .Lang "contextmenu.app"}}</div>
502 + <button type="button" class="context-menu-item" data-context-action="palette" role="menuitem">
503 + {{icon "command" "size-3.5"}}<span>{{t .Lang "shortcuts.palette"}}</span><kbd class="context-menu-kbd">Ctrl K</kbd>
504 + </button>
505 + <button type="button" class="context-menu-item" data-context-action="shortcuts" role="menuitem">
506 + {{icon "keyboard" "size-3.5"}}<span>{{t .Lang "shortcuts.title"}}</span><kbd class="context-menu-kbd">?</kbd>
507 + </button>
508 + {{if .CurrentUser}}
509 + <a class="context-menu-item" href="/new" role="menuitem">
510 + {{icon "plus" "size-3.5"}}<span>{{t .Lang "repo.new.title"}}</span>
511 + </a>
512 + <a class="context-menu-item" href="/settings" role="menuitem">
513 + {{icon "settings" "size-3.5"}}<span>{{t .Lang "nav.settings"}}</span>
514 + </a>
515 + {{end}}
357 516 </div>
358 517 </div>
359 518