mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-04 07:33:44 +09:00
enhance(ui): cleanup navbar template and styling (#39554)
Clean up the navbar template and styling, and fix the navbar stopwatch, which navigated to the issue instead of opening its popup since https://github.com/go-gitea/gitea/pull/36965. 1. Add hover background to the create and user menus 2. Simplify navbar HTML and CSS and remove Fomantic styles 3. Render the notification and stopwatch icons once instead of separate mobile and desktop copies 4. Make the stopwatch a keyboard accessible button whose popup updates on push and closes when the stopwatch stops Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
@@ -160,7 +160,6 @@ func (c TemplateContext) WindowConfig() map[string]any {
|
||||
"runModeIsProd": setting.IsProd,
|
||||
"customEmojis": setting.UI.CustomEmojisMap,
|
||||
"pageData": c.parentContext().GetData()["PageData"],
|
||||
"enableTimeTracking": setting.Service.EnableTimetracking,
|
||||
"mermaidMaxSourceCharacters": setting.MermaidMaxSourceCharacters,
|
||||
"sharedWorkerUri": public.AssetURI("web_src/js/user-events.sharedworker.ts"),
|
||||
"notificationSettings": map[string]any{
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
<nav id="navbar" aria-label="{{ctx.Locale.Tr "aria.navbar"}}">
|
||||
<div class="navbar-left">
|
||||
<!-- the logo -->
|
||||
<a class="item" id="navbar-logo" href="{{AppSubUrl}}/" aria-label="{{if .IsSigned}}{{ctx.Locale.Tr "dashboard"}}{{else}}{{ctx.Locale.Tr "home_title"}}{{end}}">
|
||||
<img width="30" height="30" src="{{AssetUrlPrefix}}/img/logo.svg" alt="{{ctx.Locale.Tr "logo"}}" aria-hidden="true">
|
||||
</a>
|
||||
<a class="item" id="navbar-logo" href="{{AppSubUrl}}/" aria-label="{{if .IsSigned}}{{ctx.Locale.Tr "dashboard"}}{{else}}{{ctx.Locale.Tr "home_title"}}{{end}}">
|
||||
<img width="30" height="30" src="{{AssetUrlPrefix}}/img/logo.svg" alt="{{ctx.Locale.Tr "logo"}}" aria-hidden="true">
|
||||
</a>
|
||||
|
||||
<!-- mobile right menu, it must be here because in mobile view, each item is a flex column, the first item is a full row column -->
|
||||
<div class="ui secondary menu navbar-mobile-right only-mobile">
|
||||
{{template "base/head_navbar_icons" dict "PageGlobalData" .PageGlobalData}}
|
||||
<button type="button" class="item ui icon mini button tw-m-0" id="navbar-expand-toggle" aria-label="{{ctx.Locale.Tr "home.nav_menu"}}">{{svg "octicon-three-bars"}}</button>
|
||||
</div>
|
||||
<button type="button" class="item btn only-mobile" id="navbar-expand-toggle" aria-label="{{ctx.Locale.Tr "home.nav_menu"}}">{{svg "octicon-three-bars"}}</button>
|
||||
|
||||
<!-- navbar links non-mobile -->
|
||||
<div class="navbar-list navbar-left">
|
||||
{{if and .IsSigned .MustChangePassword}}
|
||||
{{/* No links */}}
|
||||
{{else if .IsSigned}}
|
||||
@@ -40,8 +34,9 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- the full dropdown menus -->
|
||||
<div class="navbar-right">
|
||||
{{template "base/head_navbar_icons"}}
|
||||
|
||||
<div class="navbar-list navbar-right">
|
||||
{{if and .IsSigned .MustChangePassword}}
|
||||
<div class="ui dropdown jump item" data-tooltip-content="{{ctx.Locale.Tr "user_profile_and_more"}}">
|
||||
<span class="text">
|
||||
@@ -62,7 +57,6 @@
|
||||
</div><!-- end content avatar menu -->
|
||||
</div><!-- end dropdown avatar menu -->
|
||||
{{else if .IsSigned}}
|
||||
{{template "base/head_navbar_icons" dict "ItemExtraClass" "not-mobile" "PageGlobalData" .PageGlobalData}}
|
||||
<div class="ui dropdown jump item" data-tooltip-content="{{ctx.Locale.Tr "create_new"}}">
|
||||
<span class="flex-text-block">
|
||||
{{svg "octicon-plus"}}
|
||||
@@ -149,33 +143,6 @@
|
||||
<span class="tw-ml-1">{{ctx.Locale.Tr "sign_in"}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div><!-- end full right menu -->
|
||||
|
||||
{{$activeStopwatch := and .PageGlobalData (call .PageGlobalData.GetActiveStopwatch)}}
|
||||
{{/* always rendered so the Tippy popup has content to clone if the icon appears via a real-time push */}}
|
||||
<div class="active-stopwatch-popup tippy-target">
|
||||
<div class="flex-text-block tw-p-3">
|
||||
<a class="stopwatch-link flex-text-block muted" {{if $activeStopwatch}}href="{{$activeStopwatch.IssueLink}}"{{end}}>
|
||||
{{svg "octicon-issue-opened" 16}}
|
||||
<span class="stopwatch-issue">{{if $activeStopwatch}}{{$activeStopwatch.RepoSlug}}#{{$activeStopwatch.IssueIndex}}{{end}}</span>
|
||||
</a>
|
||||
<div class="tw-flex tw-gap-1">
|
||||
<form class="stopwatch-commit form-fetch-action" method="post" {{if $activeStopwatch}}action="{{$activeStopwatch.IssueLink}}/times/stopwatch/stop"{{end}}>
|
||||
<button
|
||||
type="submit"
|
||||
class="ui button mini compact basic icon tw-mr-0"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.stop_tracking"}}"
|
||||
>{{svg "octicon-square-fill"}}</button>
|
||||
</form>
|
||||
<form class="stopwatch-cancel form-fetch-action" method="post" {{if $activeStopwatch}}action="{{$activeStopwatch.IssueLink}}/times/stopwatch/cancel"{{end}}>
|
||||
<button
|
||||
type="submit"
|
||||
class="ui button mini compact basic icon tw-mr-0"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.cancel_tracking"}}"
|
||||
>{{svg "octicon-trash"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{{template "base/head_impersonate_banner"}}
|
||||
|
||||
@@ -1,16 +1,43 @@
|
||||
{{- $itemExtraClass := .ItemExtraClass -}}
|
||||
{{- $data := .PageGlobalData -}}
|
||||
{{if and $data $data.IsSigned}}{{/* data may not exist, for example: rendering 503 page before the PageGlobalData middleware */}}
|
||||
{{- $activeStopwatch := call $data.GetActiveStopwatch -}}
|
||||
{{- $notificationUnreadCount := call $data.GetNotificationUnreadCount -}}
|
||||
{{/* always rendered so a real-time push can reveal it without a reload */}}
|
||||
<a class="item active-stopwatch{{if not $activeStopwatch}} tw-hidden{{end}} {{$itemExtraClass}}" {{if $activeStopwatch}}href="{{$activeStopwatch.IssueLink}}" data-seconds="{{$activeStopwatch.Seconds}}"{{end}} title="{{ctx.Locale.Tr "active_stopwatch"}}">
|
||||
<div class="tw-relative flex-text-block">
|
||||
{{svg "octicon-stopwatch"}}
|
||||
<span class="header-stopwatch-dot"></span>
|
||||
{{- $data := ctx.RootData.PageGlobalData}}{{/* data may not exist, for example: rendering 503 page before the PageGlobalData middleware */}}
|
||||
{{if and $data $data.IsSigned (not ctx.RootData.MustChangePassword) -}}
|
||||
{{if EnableTimetracking}}
|
||||
{{$activeStopwatch := call $data.GetActiveStopwatch -}}
|
||||
{{/* always rendered so a real-time push can reveal it without a reload */}}
|
||||
<button type="button" class="item btn active-stopwatch {{if not $activeStopwatch}}tw-hidden{{end}}" title="{{ctx.Locale.Tr "active_stopwatch"}}" data-global-init="initActiveStopwatchNotification" {{if $activeStopwatch}}data-seconds="{{$activeStopwatch.Seconds}}"{{end}}>
|
||||
<span class="tw-relative flex-text-block">
|
||||
{{svg "octicon-stopwatch"}}
|
||||
<span class="header-stopwatch-dot"></span>
|
||||
</span>
|
||||
</button>
|
||||
{{if not $activeStopwatch}}{{$activeStopwatch = dict}}{{end}}{{/* mock an empty stopwatch */}}
|
||||
<div class="tippy-target">
|
||||
<div class="flex-text-block tw-p-3">
|
||||
<a class="stopwatch-link flex-text-block muted" href="{{$activeStopwatch.IssueLink}}">
|
||||
{{svg "octicon-issue-opened" 16}}
|
||||
<span class="stopwatch-issue">{{$activeStopwatch.RepoSlug}}#{{$activeStopwatch.IssueIndex}}</span>
|
||||
</a>
|
||||
<div class="flex-text-inline">
|
||||
<form class="stopwatch-commit form-fetch-action" method="post" action="{{$activeStopwatch.IssueLink}}/times/stopwatch/stop">
|
||||
<button
|
||||
type="submit"
|
||||
class="ui button mini compact basic icon"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.stop_tracking"}}"
|
||||
>{{svg "octicon-square-fill"}}</button>
|
||||
</form>
|
||||
<form class="stopwatch-cancel form-fetch-action" method="post" action="{{$activeStopwatch.IssueLink}}/times/stopwatch/cancel">
|
||||
<button
|
||||
type="submit"
|
||||
class="ui button mini compact basic icon"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.cancel_tracking"}}"
|
||||
>{{svg "octicon-trash"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="item {{$itemExtraClass}}" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}">
|
||||
{{end}}
|
||||
|
||||
{{$notificationUnreadCount := call $data.GetNotificationUnreadCount -}}
|
||||
<a class="item" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}">
|
||||
<div class="tw-relative flex-text-block">
|
||||
{{svg "octicon-bell"}}
|
||||
<span class="notification_count{{if not $notificationUnreadCount}} tw-hidden{{end}}">{{$notificationUnreadCount}}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch, apiCancelStopwatch, apiCloseIssue, randomString} from './utils.ts';
|
||||
import {loginUser, baseUrl, apiUserHeaders, apiCreateUser, apiCreateRepo, apiCreateIssue, apiStartStopwatch, apiCloseIssue, randomString} from './utils.ts';
|
||||
|
||||
// The /-/ws WebSocket pipeline is push-only: every event is fired by the server
|
||||
// immediately on the DB write. These tests exercise that each event type
|
||||
@@ -17,7 +17,7 @@ test.describe('events', () => {
|
||||
loginUser(page, owner),
|
||||
]);
|
||||
await page.goto('/');
|
||||
const badge = page.locator('a.not-mobile .notification_count');
|
||||
const badge = page.locator('#navbar .notification_count');
|
||||
await expect(badge).toBeHidden();
|
||||
|
||||
await expect(page.locator('html[data-user-events-connected]')).toBeAttached();
|
||||
@@ -26,7 +26,7 @@ test.describe('events', () => {
|
||||
await expect(badge).toBeVisible();
|
||||
});
|
||||
|
||||
test('stopwatch appears and hides via real-time push', async ({page, request}) => {
|
||||
test('stopwatch appears via real-time push and stops from its popup', async ({page, request}) => {
|
||||
const name = `ev-sw-push-${randomString(8)}`;
|
||||
const headers = apiUserHeaders(name);
|
||||
|
||||
@@ -40,23 +40,23 @@ test.describe('events', () => {
|
||||
]);
|
||||
// Page loads before the stopwatch starts — the icon is hidden in the rendered HTML
|
||||
await page.goto('/');
|
||||
const stopwatch = page.locator('.active-stopwatch.not-mobile');
|
||||
const stopwatch = page.getByTitle('Active Time Tracker');
|
||||
// Element must exist in the DOM (just hidden); otherwise the push has nothing to reveal.
|
||||
await expect(stopwatch).toHaveCount(1);
|
||||
await expect(stopwatch).toBeHidden();
|
||||
|
||||
await expect(page.locator('html[data-user-events-connected]')).toBeAttached();
|
||||
|
||||
// Drive both directions from outside this tab; each push must reach it
|
||||
await apiStartStopwatch(request, name, name, 1, {headers});
|
||||
await expect(stopwatch).toBeVisible();
|
||||
|
||||
await apiCancelStopwatch(request, name, name, 1, {headers});
|
||||
await stopwatch.click();
|
||||
await page.getByRole('button', {name: 'Stop Timer'}).click();
|
||||
await expect(stopwatch).toBeHidden();
|
||||
});
|
||||
|
||||
// Closing an issue stops the timer away from any stopwatch route handler.
|
||||
test('stopwatch renders when already active and hides when the issue is closed', async ({page, request}) => {
|
||||
test('stopwatch renders when already active and hides with its popup when the issue is closed', async ({page, request}) => {
|
||||
const name = `ev-sw-close-${randomString(8)}`;
|
||||
const headers = apiUserHeaders(name);
|
||||
|
||||
@@ -70,12 +70,16 @@ test.describe('events', () => {
|
||||
})(),
|
||||
]);
|
||||
await page.goto('/');
|
||||
const stopwatch = page.locator('.active-stopwatch.not-mobile');
|
||||
const stopwatch = page.getByTitle('Active Time Tracker');
|
||||
await expect(stopwatch).toBeVisible();
|
||||
await expect(page.locator('html[data-user-events-connected]')).toBeAttached();
|
||||
await stopwatch.click();
|
||||
const stopButton = page.getByRole('button', {name: 'Stop Timer'});
|
||||
await expect(stopButton).toBeVisible();
|
||||
|
||||
await apiCloseIssue(request, name, name, 1, {headers});
|
||||
await expect(stopwatch).toBeHidden();
|
||||
await expect(stopButton).toBeHidden();
|
||||
});
|
||||
|
||||
// Repro for https://github.com/go-gitea/gitea/pull/36965#issuecomment-4321282667:
|
||||
@@ -103,7 +107,7 @@ test.describe('events', () => {
|
||||
// neither of these would be present.
|
||||
await expect(page.getByRole('button', {name: 'Stop timer'})).toBeVisible();
|
||||
await expect(page.getByRole('button', {name: 'Discard timer'})).toBeVisible();
|
||||
await expect(page.locator('.active-stopwatch.not-mobile')).toBeVisible();
|
||||
await expect(page.getByTitle('Active Time Tracker')).toBeVisible();
|
||||
});
|
||||
|
||||
test('logout propagation', async ({browser, request}) => {
|
||||
|
||||
@@ -84,12 +84,6 @@ export async function apiCreateFiles(requestContext: APIRequestContext, owner: s
|
||||
}), 'apiCreateFiles');
|
||||
}
|
||||
|
||||
export async function apiCancelStopwatch(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues/${issueIndex}/stopwatch/delete`, {
|
||||
headers: headers || apiHeaders(),
|
||||
}), 'apiCancelStopwatch');
|
||||
}
|
||||
|
||||
export async function apiCloseIssue(requestContext: APIRequestContext, owner: string, repo: string, issueIndex: number, {headers}: {headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.patch(`${baseUrl()}/api/v1/repos/${owner}/${repo}/issues/${issueIndex}`, {
|
||||
headers: headers || apiHeaders(),
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
#navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--color-nav-bg);
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#navbar .navbar-left,
|
||||
#navbar .navbar-right {
|
||||
#navbar,
|
||||
.navbar-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 49px; /* +1px border-bottom */
|
||||
}
|
||||
|
||||
.navbar-left > .item,
|
||||
.navbar-right > .item,
|
||||
.navbar-mobile-right > .item {
|
||||
#navbar > .item,
|
||||
#navbar > .navbar-list > .item {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-nav-text);
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
@@ -30,81 +24,67 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#navbar .item.active {
|
||||
#navbar-logo {
|
||||
margin: 6.5px 0; /* center the 36px items in the 49px row */
|
||||
}
|
||||
|
||||
#navbar > .item.active,
|
||||
#navbar > .navbar-list > .item.active {
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
#navbar :is(a, button).item:not(.dropdown *):hover {
|
||||
#navbar > .item:hover,
|
||||
#navbar > .navbar-list > .item:hover {
|
||||
background: var(--color-nav-hover-bg);
|
||||
}
|
||||
|
||||
#navbar .item.ui.dropdown {
|
||||
#navbar > .navbar-list > .item.ui.dropdown {
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
#navbar > .navbar-left {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
#navbar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0;
|
||||
}
|
||||
#navbar-logo {
|
||||
margin-right: auto;
|
||||
}
|
||||
#navbar-expand-toggle {
|
||||
order: 1;
|
||||
}
|
||||
.navbar-list {
|
||||
display: none;
|
||||
order: 2;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
/* hide all items */
|
||||
#navbar .navbar-left > .item,
|
||||
#navbar .navbar-right > .item {
|
||||
display: none;
|
||||
.navbar-left {
|
||||
margin-top: 5px;
|
||||
}
|
||||
#navbar #navbar-logo {
|
||||
.navbar-menu-open .navbar-list {
|
||||
display: flex;
|
||||
}
|
||||
/* show the first navbar item (logo and its mobile right items) */
|
||||
#navbar .navbar-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
#navbar .navbar-mobile-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
#navbar .navbar-mobile-right > .item {
|
||||
display: flex;
|
||||
width: auto;
|
||||
}
|
||||
/* show items if the navbar is open */
|
||||
#navbar.navbar-menu-open {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
#navbar.navbar-menu-open,
|
||||
#navbar.navbar-menu-open .navbar-right {
|
||||
flex-direction: column;
|
||||
}
|
||||
#navbar.navbar-menu-open .navbar-left {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#navbar.navbar-menu-open .navbar-left > .item,
|
||||
#navbar.navbar-menu-open .navbar-right > .item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
#navbar.navbar-menu-open .navbar-left #navbar-logo {
|
||||
justify-content: flex-start;
|
||||
width: auto;
|
||||
}
|
||||
#navbar.navbar-menu-open .navbar-left .navbar-mobile-right {
|
||||
justify-content: flex-end;
|
||||
min-height: 49px;
|
||||
}
|
||||
}
|
||||
|
||||
#navbar a.item:hover .notification_count,
|
||||
#navbar a.item:hover .header-stopwatch-dot,
|
||||
#navbar .item:hover .notification_count,
|
||||
#navbar .item:hover .header-stopwatch-dot,
|
||||
#navbar .item:hover .navbar-admin-badge,
|
||||
#navbar .item.active .navbar-admin-badge {
|
||||
border-color: var(--color-nav-hover-bg);
|
||||
}
|
||||
|
||||
#navbar a.item .notification_count,
|
||||
#navbar a.item .header-stopwatch-dot,
|
||||
#navbar .item .navbar-admin-badge {
|
||||
#navbar .notification_count,
|
||||
#navbar .header-stopwatch-dot,
|
||||
#navbar .navbar-admin-badge {
|
||||
color: var(--color-nav-bg);
|
||||
padding: 0 3.75px;
|
||||
font-size: 12px;
|
||||
@@ -132,8 +112,7 @@
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
#navbar .item .navbar-admin-badge {
|
||||
position: absolute;
|
||||
#navbar .navbar-admin-badge {
|
||||
left: auto;
|
||||
right: -7px;
|
||||
bottom: -5px;
|
||||
|
||||
@@ -1,51 +1,79 @@
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {hideElem, queryElems, showElem} from '../utils/dom.ts';
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {onUserEvent} from '../modules/worker.ts';
|
||||
import type {StopwatchData} from '../types.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
|
||||
const {appSubUrl, notificationSettings, enableTimeTracking} = window.config;
|
||||
|
||||
export function initStopwatch() {
|
||||
if (!enableTimeTracking) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopwatchEls = document.querySelectorAll('.active-stopwatch');
|
||||
const stopwatchPopup = document.querySelector('.active-stopwatch-popup');
|
||||
|
||||
if (!stopwatchEls.length || !stopwatchPopup) {
|
||||
return;
|
||||
}
|
||||
const {appSubUrl, notificationSettings} = window.config;
|
||||
|
||||
export const initStopwatch = () => registerGlobalInitFunc('initActiveStopwatchNotification', (btn: HTMLElement) => {
|
||||
// Init the icon + popup even when no stopwatch is active so a real-time push has a target to toggle.
|
||||
const seconds = stopwatchEls[0]?.getAttribute('data-seconds');
|
||||
if (seconds) {
|
||||
updateStopwatchTime(parseInt(seconds));
|
||||
}
|
||||
const popup = btn.nextElementSibling!;
|
||||
const tippy = createTippy(btn, {
|
||||
content: popup,
|
||||
placement: 'bottom-end',
|
||||
trigger: 'click',
|
||||
maxWidth: 'none',
|
||||
interactive: true,
|
||||
hideOnClick: true,
|
||||
theme: 'default',
|
||||
});
|
||||
|
||||
for (const stopwatchEl of stopwatchEls) {
|
||||
createTippy(stopwatchEl, {
|
||||
content: stopwatchPopup.cloneNode(true) as Element,
|
||||
placement: 'bottom-end',
|
||||
trigger: 'click',
|
||||
maxWidth: 'none',
|
||||
interactive: true,
|
||||
hideOnClick: true,
|
||||
theme: 'default',
|
||||
onShow(instance) {
|
||||
// Re-clone on every open so the popup reflects the latest stopwatch state,
|
||||
// including the case where the icon became visible via a real-time push.
|
||||
instance.setContent(stopwatchPopup.cloneNode(true) as Element);
|
||||
},
|
||||
});
|
||||
}
|
||||
// TODO: This flickers on page load, we could avoid this by making a custom element to render time periods.
|
||||
const updateStopwatchTime = (seconds: number) => {
|
||||
const hours = seconds / 3600 || 0;
|
||||
const minutes = seconds / 60 || 0;
|
||||
btn.querySelector('.header-stopwatch-dot')!.textContent = hours >= 1 ? `${Math.round(hours)}h` : `${Math.round(minutes)}m`;
|
||||
};
|
||||
|
||||
const updateStopwatchData = (data: Array<StopwatchData>) => {
|
||||
const watch = data[0];
|
||||
if (!watch) {
|
||||
tippy.hide();
|
||||
hideElem(btn);
|
||||
return false;
|
||||
}
|
||||
const {repo_owner_name, repo_name, issue_index, seconds} = watch;
|
||||
const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
|
||||
popup.querySelector('.stopwatch-link')!.setAttribute('href', issueUrl);
|
||||
popup.querySelector('.stopwatch-commit')!.setAttribute('action', `${issueUrl}/times/stopwatch/stop`);
|
||||
popup.querySelector('.stopwatch-cancel')!.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`);
|
||||
popup.querySelector('.stopwatch-issue')!.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`;
|
||||
updateStopwatchTime(seconds);
|
||||
showElem(btn);
|
||||
return true;
|
||||
};
|
||||
|
||||
const updateStopwatch = async () => {
|
||||
try {
|
||||
const response = await GET(`${appSubUrl}/user/stopwatches`);
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch stopwatch data');
|
||||
return false;
|
||||
}
|
||||
return updateStopwatchData(await response.json());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const startPeriodicPoller = (timeout: number) => {
|
||||
if (timeout <= 0 || !Number.isFinite(timeout)) return;
|
||||
setTimeout(() => updateStopwatchWithCallback(startPeriodicPoller, timeout), timeout);
|
||||
setTimeout(async () => {
|
||||
if (!await updateStopwatch()) {
|
||||
timeout = notificationSettings.MinTimeout;
|
||||
} else if (timeout < notificationSettings.MaxTimeout) {
|
||||
timeout += notificationSettings.TimeoutStep;
|
||||
}
|
||||
startPeriodicPoller(timeout);
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
const seconds = btn.getAttribute('data-seconds');
|
||||
if (seconds) updateStopwatchTime(parseInt(seconds));
|
||||
|
||||
let pollerStarted = false;
|
||||
onUserEvent('stopwatches', (msg) => updateStopwatchData(msg.eventData));
|
||||
// On each (re)connect, reconcile stopwatch state from the server to recover any push dropped during the connect gap.
|
||||
@@ -55,60 +83,4 @@ export function initStopwatch() {
|
||||
pollerStarted = true;
|
||||
startPeriodicPoller(notificationSettings.MinTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
async function updateStopwatchWithCallback(callback: (timeout: number) => void, timeout: number) {
|
||||
const isSet = await updateStopwatch();
|
||||
|
||||
if (!isSet) {
|
||||
timeout = notificationSettings.MinTimeout;
|
||||
} else if (timeout < notificationSettings.MaxTimeout) {
|
||||
timeout += notificationSettings.TimeoutStep;
|
||||
}
|
||||
|
||||
callback(timeout);
|
||||
}
|
||||
|
||||
async function updateStopwatch() {
|
||||
try {
|
||||
const response = await GET(`${appSubUrl}/user/stopwatches`);
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch stopwatch data');
|
||||
return false;
|
||||
}
|
||||
const data = await response.json();
|
||||
return updateStopwatchData(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStopwatchData(data: Array<StopwatchData>) {
|
||||
if (!data) return;
|
||||
const watch = data[0];
|
||||
const btnEls = document.querySelectorAll('.active-stopwatch');
|
||||
if (!watch) {
|
||||
hideElem(btnEls);
|
||||
} else {
|
||||
const {repo_owner_name, repo_name, issue_index, seconds} = watch;
|
||||
const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
|
||||
for (const btnEl of btnEls) btnEl.setAttribute('href', issueUrl);
|
||||
document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl);
|
||||
document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`);
|
||||
document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`);
|
||||
const stopwatchIssue = document.querySelector('.stopwatch-issue');
|
||||
if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`;
|
||||
updateStopwatchTime(seconds);
|
||||
showElem(btnEls);
|
||||
}
|
||||
return Boolean(data.length);
|
||||
}
|
||||
|
||||
// TODO: This flickers on page load, we could avoid this by making a custom element to render time periods.
|
||||
function updateStopwatchTime(seconds: number) {
|
||||
const hours = seconds / 3600 || 0;
|
||||
const minutes = seconds / 60 || 0;
|
||||
const timeText = hours >= 1 ? `${Math.round(hours)}h` : `${Math.round(minutes)}m`;
|
||||
queryElems(document, '.header-stopwatch-dot', (el) => el.textContent = timeText);
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
-1
@@ -53,7 +53,6 @@ interface Window {
|
||||
TimeoutStep: number,
|
||||
MaxTimeout: number,
|
||||
},
|
||||
enableTimeTracking: boolean,
|
||||
mermaidMaxSourceCharacters: number,
|
||||
i18n: Record<string, string>,
|
||||
frontendInited: boolean,
|
||||
|
||||
@@ -9,7 +9,6 @@ window.config = {
|
||||
customEmojis: {},
|
||||
pageData: {},
|
||||
notificationSettings: {MinTimeout: 0, TimeoutStep: 0, MaxTimeout: 0},
|
||||
enableTimeTracking: true,
|
||||
mermaidMaxSourceCharacters: 5000,
|
||||
i18n: {},
|
||||
frontendInited: false,
|
||||
|
||||
Reference in New Issue
Block a user