mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 09:31:53 +01:00 
			
		
		
		
	Rework and fix stopwatch (#30732)
Fixes https://github.com/go-gitea/gitea/issues/30721 and overhauls the stopwatch. Time is now shown inside the "dot" icon and on both mobile and desktop. All rendering is now done by `<relative-time>`, the `pretty-ms` dependency is dropped. Desktop: <img width="557" alt="Screenshot 2024-04-29 at 22 33 27" src="https://github.com/go-gitea/gitea/assets/115237/3a46cdbf-6af2-4bf9-b07f-021348badaac"> Mobile: <img width="640" alt="Screenshot 2024-04-29 at 22 34 19" src="https://github.com/go-gitea/gitea/assets/115237/8a2beea7-bd5d-473f-8fff-66f63fd50877"> Note for tippy: Previously, tippy instances defaulted to "menu" theme, but that theme is really only meant for `.ui.menu`, so it was not optimal for the stopwatch popover. This introduces a unopinionated `default` theme that has no padding and should be suitable for all content. I reviewed all existing uses and explicitely set the desired `theme` on all of them.
This commit is contained in:
		
							parent
							
								
									5f05e7b41a
								
							
						
					
					
						commit
						564102ce89
					
				
							
								
								
									
										26
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										26
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -42,7 +42,6 @@ | ||||
|         "postcss": "8.4.38", | ||||
|         "postcss-loader": "8.1.1", | ||||
|         "postcss-nesting": "12.1.2", | ||||
|         "pretty-ms": "9.0.0", | ||||
|         "sortablejs": "1.15.2", | ||||
|         "swagger-ui-dist": "5.17.2", | ||||
|         "tailwindcss": "3.4.3", | ||||
| @ -9170,17 +9169,6 @@ | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/parse-ms": { | ||||
|       "version": "4.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", | ||||
|       "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", | ||||
|       "engines": { | ||||
|         "node": ">=18" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/path-exists": { | ||||
|       "version": "4.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", | ||||
| @ -9772,20 +9760,6 @@ | ||||
|         "url": "https://github.com/chalk/ansi-styles?sponsor=1" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/pretty-ms": { | ||||
|       "version": "9.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.0.0.tgz", | ||||
|       "integrity": "sha512-E9e9HJ9R9NasGOgPaPE8VMeiPKAyWR5jcFpNnwIejslIhWqdqOrb2wShBsncMPUb+BcCd2OPYfh7p2W6oemTng==", | ||||
|       "dependencies": { | ||||
|         "parse-ms": "^4.0.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=18" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "url": "https://github.com/sponsors/sindresorhus" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/printable-characters": { | ||||
|       "version": "1.0.42", | ||||
|       "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", | ||||
|  | ||||
| @ -41,7 +41,6 @@ | ||||
|     "postcss": "8.4.38", | ||||
|     "postcss-loader": "8.1.1", | ||||
|     "postcss-nesting": "12.1.2", | ||||
|     "pretty-ms": "9.0.0", | ||||
|     "sortablejs": "1.15.2", | ||||
|     "swagger-ui-dist": "5.17.2", | ||||
|     "tailwindcss": "3.4.3", | ||||
|  | ||||
| @ -12,6 +12,14 @@ | ||||
| 
 | ||||
| 		<!-- 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 item navbar-mobile-right only-mobile"> | ||||
| 			{{if and .IsSigned EnableTimetracking .ActiveStopwatch}} | ||||
| 			<a id="mobile-stopwatch-icon" class="active-stopwatch item tw-mx-0" href="{{.ActiveStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}" data-seconds="{{.ActiveStopwatch.Seconds}}"> | ||||
| 				<div class="tw-relative"> | ||||
| 					{{svg "octicon-stopwatch"}} | ||||
| 					<span class="header-stopwatch-dot"></span> | ||||
| 				</div> | ||||
| 			</a> | ||||
| 			{{end}} | ||||
| 			{{if .IsSigned}} | ||||
| 			<a id="mobile-notifications-icon" class="item tw-w-auto tw-p-2" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}" aria-label="{{ctx.Locale.Tr "notifications"}}"> | ||||
| 				<div class="tw-relative"> | ||||
| @ -74,41 +82,13 @@ | ||||
| 				</div><!-- end content avatar menu --> | ||||
| 			</div><!-- end dropdown avatar menu --> | ||||
| 		{{else if .IsSigned}} | ||||
| 			{{if EnableTimetracking}} | ||||
| 			<a class="active-stopwatch-trigger item tw-mx-0{{if not .ActiveStopwatch}} tw-hidden{{end}}" href="{{.ActiveStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}"> | ||||
| 			{{if and EnableTimetracking .ActiveStopwatch}} | ||||
| 			<a class="item not-mobile active-stopwatch tw-mx-0" href="{{.ActiveStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}" data-seconds="{{.ActiveStopwatch.Seconds}}"> | ||||
| 				<div class="tw-relative"> | ||||
| 					{{svg "octicon-stopwatch"}} | ||||
| 					<span class="header-stopwatch-dot"></span> | ||||
| 				</div> | ||||
| 				<span class="only-mobile tw-ml-2">{{ctx.Locale.Tr "active_stopwatch"}}</span> | ||||
| 			</a> | ||||
| 			<div class="active-stopwatch-popup item tippy-target tw-p-2"> | ||||
| 				<div class="tw-flex tw-items-center"> | ||||
| 					<a class="stopwatch-link tw-flex tw-items-center" href="{{.ActiveStopwatch.IssueLink}}"> | ||||
| 						{{svg "octicon-issue-opened" 16 "tw-mr-2"}} | ||||
| 						<span class="stopwatch-issue">{{.ActiveStopwatch.RepoSlug}}#{{.ActiveStopwatch.IssueIndex}}</span> | ||||
| 						<span class="ui primary label stopwatch-time tw-my-0 tw-mx-4" data-seconds="{{.ActiveStopwatch.Seconds}}"> | ||||
| 							{{if .ActiveStopwatch}}{{Sec2Time .ActiveStopwatch.Seconds}}{{end}} | ||||
| 						</span> | ||||
| 					</a> | ||||
| 					<form class="stopwatch-commit" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/toggle"> | ||||
| 						{{.CsrfTokenHtml}} | ||||
| 						<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" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/cancel"> | ||||
| 						{{.CsrfTokenHtml}} | ||||
| 						<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> | ||||
| 			{{end}} | ||||
| 
 | ||||
| 			<a class="item not-mobile tw-mx-0" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}" aria-label="{{ctx.Locale.Tr "notifications"}}"> | ||||
| @ -202,4 +182,33 @@ | ||||
| 			</a> | ||||
| 		{{end}} | ||||
| 	</div><!-- end full right menu --> | ||||
| 
 | ||||
| 	{{if and .IsSigned EnableTimetracking .ActiveStopwatch}} | ||||
| 		<div class="active-stopwatch-popup tippy-target"> | ||||
| 			<div class="tw-flex tw-items-center tw-gap-2 tw-p-3"> | ||||
| 				<a class="stopwatch-link tw-flex tw-items-center tw-gap-2 muted" href="{{.ActiveStopwatch.IssueLink}}"> | ||||
| 					{{svg "octicon-issue-opened" 16}} | ||||
| 					<span class="stopwatch-issue">{{.ActiveStopwatch.RepoSlug}}#{{.ActiveStopwatch.IssueIndex}}</span> | ||||
| 				</a> | ||||
| 				<div class="tw-flex tw-gap-1"> | ||||
| 					<form class="stopwatch-commit" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/toggle"> | ||||
| 						{{.CsrfTokenHtml}} | ||||
| 						<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" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/cancel"> | ||||
| 						{{.CsrfTokenHtml}} | ||||
| 						<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> | ||||
| 	{{end}} | ||||
| </nav> | ||||
|  | ||||
| @ -103,19 +103,12 @@ | ||||
|     width: 50%; | ||||
|     min-height: 48px; | ||||
|   } | ||||
|   #navbar #mobile-stopwatch-icon, | ||||
|   #navbar #mobile-notifications-icon { | ||||
|     margin-right: 6px !important; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| #navbar a.item .notification_count { | ||||
|   color: var(--color-nav-bg); | ||||
|   padding: 0 3.75px; | ||||
|   font-size: 12px; | ||||
|   line-height: 12px; | ||||
|   font-weight: var(--font-weight-bold); | ||||
| } | ||||
| 
 | ||||
| #navbar a.item:hover .notification_count, | ||||
| #navbar a.item:hover .header-stopwatch-dot { | ||||
|   border-color: var(--color-nav-hover-bg); | ||||
| @ -123,6 +116,11 @@ | ||||
| 
 | ||||
| #navbar a.item .notification_count, | ||||
| #navbar a.item .header-stopwatch-dot { | ||||
|   color: var(--color-nav-bg); | ||||
|   padding: 0 3.75px; | ||||
|   font-size: 12px; | ||||
|   line-height: 12px; | ||||
|   font-weight: var(--font-weight-bold); | ||||
|   background: var(--color-primary); | ||||
|   border: 2px solid var(--color-nav-bg); | ||||
|   position: absolute; | ||||
| @ -135,6 +133,8 @@ | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   z-index: 1; /* prevent menu button background from overlaying icon */ | ||||
|   user-select: none; | ||||
|   white-space: nowrap; | ||||
| } | ||||
| 
 | ||||
| .secondary-nav { | ||||
|  | ||||
| @ -16,8 +16,8 @@ | ||||
| 
 | ||||
| .tippy-box { | ||||
|   position: relative; | ||||
|   background-color: var(--color-body); | ||||
|   color: var(--color-secondary-dark-6); | ||||
|   background-color: var(--color-menu); | ||||
|   color: var(--color-text); | ||||
|   border: 1px solid var(--color-secondary); | ||||
|   border-radius: var(--border-radius); | ||||
|   font-size: 1rem; | ||||
| @ -25,7 +25,6 @@ | ||||
| 
 | ||||
| .tippy-content { | ||||
|   position: relative; | ||||
|   padding: 1rem; /* if you need different padding, use different data-theme */ | ||||
|   z-index: 1; | ||||
| } | ||||
| 
 | ||||
| @ -166,5 +165,5 @@ | ||||
| } | ||||
| 
 | ||||
| .tippy-svg-arrow-inner { | ||||
|   fill: var(--color-body); | ||||
|   fill: var(--color-menu); | ||||
| } | ||||
|  | ||||
| @ -18,6 +18,7 @@ export function attachRefIssueContextPopup(refIssues) { | ||||
|     if (!owner) return; | ||||
| 
 | ||||
|     const el = document.createElement('div'); | ||||
|     el.classList.add('tw-p-3'); | ||||
|     refIssue.parentNode.insertBefore(el, refIssue.nextSibling); | ||||
| 
 | ||||
|     const view = createApp(ContextPopup); | ||||
| @ -30,6 +31,7 @@ export function attachRefIssueContextPopup(refIssues) { | ||||
|     } | ||||
| 
 | ||||
|     createTippy(refIssue, { | ||||
|       theme: 'default', | ||||
|       content: el, | ||||
|       placement: 'top-start', | ||||
|       interactive: true, | ||||
|  | ||||
| @ -113,6 +113,7 @@ function showLineButton() { | ||||
|   btn.closest('.code-view').append(menu.cloneNode(true)); | ||||
| 
 | ||||
|   createTippy(btn, { | ||||
|     theme: 'menu', | ||||
|     trigger: 'click', | ||||
|     hideOnClick: true, | ||||
|     content: menu, | ||||
|  | ||||
| @ -502,6 +502,7 @@ export function initRepoPullRequestReview() { | ||||
|   if ($reviewBtn.length && $panel.length) { | ||||
|     const tippy = createTippy($reviewBtn[0], { | ||||
|       content: $panel[0], | ||||
|       theme: 'default', | ||||
|       placement: 'bottom', | ||||
|       trigger: 'click', | ||||
|       maxWidth: 'none', | ||||
|  | ||||
| @ -1,4 +1,3 @@ | ||||
| import prettyMilliseconds from 'pretty-ms'; | ||||
| import {createTippy} from '../modules/tippy.js'; | ||||
| import {GET} from '../modules/fetch.js'; | ||||
| import {hideElem, showElem} from '../utils/dom.js'; | ||||
| @ -10,28 +9,31 @@ export function initStopwatch() { | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   const stopwatchEl = document.querySelector('.active-stopwatch-trigger'); | ||||
|   const stopwatchEls = document.querySelectorAll('.active-stopwatch'); | ||||
|   const stopwatchPopup = document.querySelector('.active-stopwatch-popup'); | ||||
| 
 | ||||
|   if (!stopwatchEl || !stopwatchPopup) { | ||||
|   if (!stopwatchEls.length || !stopwatchPopup) { | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   stopwatchEl.removeAttribute('href'); // intended for noscript mode only
 | ||||
| 
 | ||||
|   createTippy(stopwatchEl, { | ||||
|     content: stopwatchPopup, | ||||
|     placement: 'bottom-end', | ||||
|     trigger: 'click', | ||||
|     maxWidth: 'none', | ||||
|     interactive: true, | ||||
|     hideOnClick: true, | ||||
|   }); | ||||
| 
 | ||||
|   // global stop watch (in the head_navbar), it should always work in any case either the EventSource or the PeriodicPoller is used.
 | ||||
|   const currSeconds = document.querySelector('.stopwatch-time')?.getAttribute('data-seconds'); | ||||
|   if (currSeconds) { | ||||
|     updateStopwatchTime(currSeconds); | ||||
|   const seconds = stopwatchEls[0]?.getAttribute('data-seconds'); | ||||
|   if (seconds) { | ||||
|     updateStopwatchTime(parseInt(seconds)); | ||||
|   } | ||||
| 
 | ||||
|   for (const stopwatchEl of stopwatchEls) { | ||||
|     stopwatchEl.removeAttribute('href'); // intended for noscript mode only
 | ||||
| 
 | ||||
|     createTippy(stopwatchEl, { | ||||
|       content: stopwatchPopup.cloneNode(true), | ||||
|       placement: 'bottom-end', | ||||
|       trigger: 'click', | ||||
|       maxWidth: 'none', | ||||
|       interactive: true, | ||||
|       hideOnClick: true, | ||||
|       theme: 'default', | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   let usingPeriodicPoller = false; | ||||
| @ -124,10 +126,9 @@ async function updateStopwatch() { | ||||
| 
 | ||||
| function updateStopwatchData(data) { | ||||
|   const watch = data[0]; | ||||
|   const btnEl = document.querySelector('.active-stopwatch-trigger'); | ||||
|   const btnEls = document.querySelectorAll('.active-stopwatch'); | ||||
|   if (!watch) { | ||||
|     clearStopwatchTimer(); | ||||
|     hideElem(btnEl); | ||||
|     hideElem(btnEls); | ||||
|   } else { | ||||
|     const {repo_owner_name, repo_name, issue_index, seconds} = watch; | ||||
|     const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`; | ||||
| @ -137,31 +138,28 @@ function updateStopwatchData(data) { | ||||
|     const stopwatchIssue = document.querySelector('.stopwatch-issue'); | ||||
|     if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`; | ||||
|     updateStopwatchTime(seconds); | ||||
|     showElem(btnEl); | ||||
|     showElem(btnEls); | ||||
|   } | ||||
|   return Boolean(data.length); | ||||
| } | ||||
| 
 | ||||
| let updateTimeIntervalId = null; // holds setInterval id when active
 | ||||
| function clearStopwatchTimer() { | ||||
|   if (updateTimeIntervalId !== null) { | ||||
|     clearInterval(updateTimeIntervalId); | ||||
|     updateTimeIntervalId = null; | ||||
| // TODO: This flickers on page load, we could avoid this by making a custom
 | ||||
| // element to render time periods. Feeding a datetime in backend does not work
 | ||||
| // when time zone between server and client differs.
 | ||||
| function updateStopwatchTime(seconds) { | ||||
|   if (!Number.isFinite(seconds)) return; | ||||
|   const datetime = (new Date(Date.now() - seconds * 1000)).toISOString(); | ||||
|   for (const parent of document.querySelectorAll('.header-stopwatch-dot')) { | ||||
|     const existing = parent.querySelector(':scope > relative-time'); | ||||
|     if (existing) { | ||||
|       existing.setAttribute('datetime', datetime); | ||||
|     } else { | ||||
|       const el = document.createElement('relative-time'); | ||||
|       el.setAttribute('format', 'micro'); | ||||
|       el.setAttribute('datetime', datetime); | ||||
|       el.setAttribute('lang', 'en-US'); | ||||
|       el.setAttribute('title', ''); // make <relative-time> show no title and therefor no tooltip
 | ||||
|       parent.append(el); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| function updateStopwatchTime(seconds) { | ||||
|   const secs = parseInt(seconds); | ||||
|   if (!Number.isFinite(secs)) return; | ||||
| 
 | ||||
|   clearStopwatchTimer(); | ||||
|   const stopwatch = document.querySelector('.stopwatch-time'); | ||||
|   // TODO: replace with <relative-time> similar to how system status up time is shown
 | ||||
|   const start = Date.now(); | ||||
|   const updateUi = () => { | ||||
|     const delta = Date.now() - start; | ||||
|     const dur = prettyMilliseconds(secs * 1000 + delta, {compact: true}); | ||||
|     if (stopwatch) stopwatch.textContent = dur; | ||||
|   }; | ||||
|   updateUi(); | ||||
|   updateTimeIntervalId = setInterval(updateUi, 1000); | ||||
| } | ||||
|  | ||||
| @ -37,8 +37,10 @@ export function createTippy(target, opts = {}) { | ||||
|       return onShow?.(instance); | ||||
|     }, | ||||
|     arrow: arrow || (theme === 'bare' ? false : arrowSvg), | ||||
|     role: role || 'menu', // HTML role attribute
 | ||||
|     theme: theme || role || 'menu', // CSS theme, either "tooltip", "menu", "box-with-header" or "bare"
 | ||||
|     // HTML role attribute, ideally the default role would be "popover" but it does not exist
 | ||||
|     role: role || 'menu', | ||||
|     // CSS theme, either "default", "tooltip", "menu", "box-with-header" or "bare"
 | ||||
|     theme: theme || role || 'default', | ||||
|     plugins: [followCursor], | ||||
|     ...other, | ||||
|   }); | ||||
|  | ||||
| @ -131,6 +131,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement { | ||||
|       interactive: true, | ||||
|       placement: 'bottom-end', | ||||
|       role: 'menu', | ||||
|       theme: 'menu', | ||||
|       content: this.tippyContent, | ||||
|       onShow: () => { // FIXME: onShown doesn't work (never be called)
 | ||||
|         setTimeout(() => { | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user