mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-03 15:07:12 +02:00
Merge branch 'main' into main
This commit is contained in:
714 files changed
+12752
-7192
No files matched your search
@@ -55,5 +55,5 @@
|
||||
padding: 1em 1.5em;
|
||||
border: 1px solid var(--color-info-border);
|
||||
background: var(--color-info-bg);
|
||||
color: var(--color-info-text);
|
||||
color: var(--color-text);
|
||||
}
|
||||
@@ -501,10 +501,10 @@ img.ui.avatar,
|
||||
}
|
||||
|
||||
blockquote.attention-note {
|
||||
border-left-color: var(--color-blue-dark-1);
|
||||
border-left-color: var(--color-info-text);
|
||||
}
|
||||
strong.attention-note, svg.attention-note {
|
||||
color: var(--color-blue-dark-1);
|
||||
color: var(--color-info-text);
|
||||
}
|
||||
|
||||
blockquote.attention-tip {
|
||||
@@ -515,10 +515,10 @@ strong.attention-tip, svg.attention-tip {
|
||||
}
|
||||
|
||||
blockquote.attention-important {
|
||||
border-left-color: var(--color-violet-dark-1);
|
||||
border-left-color: var(--color-priority-text);
|
||||
}
|
||||
strong.attention-important, svg.attention-important {
|
||||
color: var(--color-violet-dark-1);
|
||||
color: var(--color-priority-text);
|
||||
}
|
||||
|
||||
blockquote.attention-warning {
|
||||
@@ -529,10 +529,10 @@ strong.attention-warning, svg.attention-warning {
|
||||
}
|
||||
|
||||
blockquote.attention-caution {
|
||||
border-left-color: var(--color-red-dark-1);
|
||||
border-left-color: var(--color-error-text);
|
||||
}
|
||||
strong.attention-caution, svg.attention-caution {
|
||||
color: var(--color-red-dark-1);
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
/* FIXME: this is a longstanding dirty patch since 2015, it only makes the pages more messy and shouldn't be used */
|
||||
|
||||
@@ -32,26 +32,29 @@
|
||||
fill: currentcolor !important;
|
||||
}
|
||||
|
||||
/* root legend */
|
||||
#user-heatmap .vch__container > .vch__legend {
|
||||
#user-heatmap .heatmap-footer {
|
||||
display: flex;
|
||||
font-size: 11px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* for the "Less" and "More" legend */
|
||||
#user-heatmap .vch__legend .vch__legend {
|
||||
/* "Less [colors] More" scale */
|
||||
#user-heatmap .heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: right;
|
||||
}
|
||||
|
||||
#user-heatmap .vch__legend .vch__legend div:first-child,
|
||||
#user-heatmap .vch__legend .vch__legend div:last-child {
|
||||
#user-heatmap .heatmap-legend-svg {
|
||||
margin-right: -12px;
|
||||
}
|
||||
|
||||
#user-heatmap .heatmap-legend > div:first-child,
|
||||
#user-heatmap .heatmap-legend > div:last-child {
|
||||
display: inline-block;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
#user-heatmap .vch__day__square:hover {
|
||||
#user-heatmap .heatmap-day:hover {
|
||||
outline: 1.5px solid var(--color-text);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -122,14 +122,3 @@ a.ui.card:hover {
|
||||
color: var(--color-text);
|
||||
border-top-color: var(--color-secondary-light-1) !important;
|
||||
}
|
||||
|
||||
.ui.three.cards {
|
||||
margin-left: -1em;
|
||||
margin-right: -1em;
|
||||
}
|
||||
|
||||
.ui.three.cards > .card {
|
||||
width: calc(33.33333333333333% - 2em);
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
@@ -32,23 +32,6 @@
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.ui.comments .comment > .comments {
|
||||
margin: 0 0 0.5em 0.5em;
|
||||
padding: 1em 0 1em 1em;
|
||||
}
|
||||
|
||||
.ui.comments .comment > .comments::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.ui.comments .comment > .comments .comment {
|
||||
border: none;
|
||||
border-top: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.ui.comments .comment > .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -56,21 +39,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui.comments .comment .metadata {
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.ui.comments .comment .metadata > * {
|
||||
display: inline-block;
|
||||
margin: 0 0.5em 0 0;
|
||||
}
|
||||
|
||||
.ui.comments .comment .metadata > :last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.ui.comments .comment .text {
|
||||
margin: 0.25em 0 0.5em;
|
||||
font-size: 1em;
|
||||
|
||||
@@ -124,18 +124,12 @@ textarea:focus,
|
||||
.ui.form .fields:not(.grouped):not(.inline) .field:not(:only-child) .ui.toggle.checkbox {
|
||||
margin-top: 2.21428571em;
|
||||
}
|
||||
.ui.form .fields:not(.grouped):not(.inline) .field:not(:only-child) .ui.slider.checkbox {
|
||||
margin-top: 2.61428571em;
|
||||
}
|
||||
.ui.ui.form .field .fields .field:not(:only-child) .ui.checkbox {
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
.ui.ui.form .field .fields .field:not(:only-child) .ui.toggle.checkbox {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.ui.ui.form .field .fields .field:not(:only-child) .ui.slider.checkbox {
|
||||
margin-top: 0.7em;
|
||||
}
|
||||
|
||||
.ui.form .field > .selection.dropdown {
|
||||
min-width: 14em; /* matches the default min width */
|
||||
@@ -204,14 +198,14 @@ textarea:focus,
|
||||
.ui.form .field.error input {
|
||||
background-color: var(--color-error-bg);
|
||||
border-color: var(--color-error-border);
|
||||
color: var(--color-error-text);
|
||||
color: var(--color-input-text);
|
||||
}
|
||||
.ui.form .field.error textarea:focus,
|
||||
.ui.form .field.error select:focus,
|
||||
.ui.form .field.error input:focus {
|
||||
background-color: var(--color-error-bg);
|
||||
border-color: var(--color-error-border);
|
||||
color: var(--color-error-text);
|
||||
color: var(--color-input-text);
|
||||
}
|
||||
|
||||
.ui.form .field.error select {
|
||||
@@ -289,15 +283,11 @@ textarea:focus,
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: none;
|
||||
}
|
||||
.ui.form .grouped.fields .field,
|
||||
.ui.form .grouped.inline.fields .field {
|
||||
.ui.form .grouped.fields .field {
|
||||
display: block;
|
||||
margin: 0.5em 0;
|
||||
padding: 0;
|
||||
}
|
||||
.ui.form .grouped.inline.fields .ui.checkbox {
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
.ui.form .fields {
|
||||
display: flex;
|
||||
|
||||
@@ -10,10 +10,6 @@
|
||||
margin: -1rem;
|
||||
}
|
||||
|
||||
.ui.relaxed.grid {
|
||||
margin-left: -1.5rem;
|
||||
margin-right: -1.5rem;
|
||||
}
|
||||
.ui[class*="very relaxed"].grid {
|
||||
margin-left: -2.5rem;
|
||||
margin-right: -2.5rem;
|
||||
@@ -71,18 +67,10 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ui.grid .aligned.row > .column > .segment:not(.compact):not(.attached),
|
||||
.ui.aligned.grid .column > .segment:not(.compact):not(.attached) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui.grid .row + .ui.divider {
|
||||
flex-grow: 1;
|
||||
margin: 1rem;
|
||||
}
|
||||
.ui.grid .column + .ui.vertical.divider {
|
||||
height: calc(50% - 1rem);
|
||||
}
|
||||
|
||||
.ui.grid > .row > .column:last-child > .horizontal.segment,
|
||||
.ui.grid > .column:last-child > .horizontal.segment {
|
||||
@@ -140,119 +128,14 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui[class*="one column"].grid > .row > .column,
|
||||
.ui[class*="one column"].grid > .column:not(.row) {
|
||||
width: 100%;
|
||||
}
|
||||
.ui[class*="two column"].grid > .row > .column,
|
||||
.ui[class*="two column"].grid > .column:not(.row) {
|
||||
width: 50%;
|
||||
}
|
||||
.ui[class*="three column"].grid > .row > .column,
|
||||
.ui[class*="three column"].grid > .column:not(.row) {
|
||||
width: 33.33333333%;
|
||||
}
|
||||
.ui[class*="four column"].grid > .row > .column,
|
||||
.ui[class*="four column"].grid > .column:not(.row) {
|
||||
width: 25%;
|
||||
}
|
||||
.ui[class*="five column"].grid > .row > .column,
|
||||
.ui[class*="five column"].grid > .column:not(.row) {
|
||||
width: 20%;
|
||||
}
|
||||
.ui[class*="six column"].grid > .row > .column,
|
||||
.ui[class*="six column"].grid > .column:not(.row) {
|
||||
width: 16.66666667%;
|
||||
}
|
||||
.ui[class*="seven column"].grid > .row > .column,
|
||||
.ui[class*="seven column"].grid > .column:not(.row) {
|
||||
width: 14.28571429%;
|
||||
}
|
||||
.ui[class*="eight column"].grid > .row > .column,
|
||||
.ui[class*="eight column"].grid > .column:not(.row) {
|
||||
width: 12.5%;
|
||||
}
|
||||
.ui[class*="nine column"].grid > .row > .column,
|
||||
.ui[class*="nine column"].grid > .column:not(.row) {
|
||||
width: 11.11111111%;
|
||||
}
|
||||
.ui[class*="ten column"].grid > .row > .column,
|
||||
.ui[class*="ten column"].grid > .column:not(.row) {
|
||||
width: 10%;
|
||||
}
|
||||
.ui[class*="eleven column"].grid > .row > .column,
|
||||
.ui[class*="eleven column"].grid > .column:not(.row) {
|
||||
width: 9.09090909%;
|
||||
}
|
||||
.ui[class*="twelve column"].grid > .row > .column,
|
||||
.ui[class*="twelve column"].grid > .column:not(.row) {
|
||||
width: 8.33333333%;
|
||||
}
|
||||
.ui[class*="thirteen column"].grid > .row > .column,
|
||||
.ui[class*="thirteen column"].grid > .column:not(.row) {
|
||||
width: 7.69230769%;
|
||||
}
|
||||
.ui[class*="fourteen column"].grid > .row > .column,
|
||||
.ui[class*="fourteen column"].grid > .column:not(.row) {
|
||||
width: 7.14285714%;
|
||||
}
|
||||
.ui[class*="fifteen column"].grid > .row > .column,
|
||||
.ui[class*="fifteen column"].grid > .column:not(.row) {
|
||||
width: 6.66666667%;
|
||||
}
|
||||
.ui[class*="sixteen column"].grid > .row > .column,
|
||||
.ui[class*="sixteen column"].grid > .column:not(.row) {
|
||||
width: 6.25%;
|
||||
}
|
||||
|
||||
.ui.grid > [class*="one column"].row > .column {
|
||||
width: 100% !important;
|
||||
}
|
||||
.ui.grid > [class*="two column"].row > .column {
|
||||
width: 50% !important;
|
||||
}
|
||||
.ui.grid > [class*="three column"].row > .column {
|
||||
width: 33.33333333% !important;
|
||||
}
|
||||
.ui.grid > [class*="four column"].row > .column {
|
||||
width: 25% !important;
|
||||
}
|
||||
.ui.grid > [class*="five column"].row > .column {
|
||||
width: 20% !important;
|
||||
}
|
||||
.ui.grid > [class*="six column"].row > .column {
|
||||
width: 16.66666667% !important;
|
||||
}
|
||||
.ui.grid > [class*="seven column"].row > .column {
|
||||
width: 14.28571429% !important;
|
||||
}
|
||||
.ui.grid > [class*="eight column"].row > .column {
|
||||
width: 12.5% !important;
|
||||
}
|
||||
.ui.grid > [class*="nine column"].row > .column {
|
||||
width: 11.11111111% !important;
|
||||
}
|
||||
.ui.grid > [class*="ten column"].row > .column {
|
||||
width: 10% !important;
|
||||
}
|
||||
.ui.grid > [class*="eleven column"].row > .column {
|
||||
width: 9.09090909% !important;
|
||||
}
|
||||
.ui.grid > [class*="twelve column"].row > .column {
|
||||
width: 8.33333333% !important;
|
||||
}
|
||||
.ui.grid > [class*="thirteen column"].row > .column {
|
||||
width: 7.69230769% !important;
|
||||
}
|
||||
.ui.grid > [class*="fourteen column"].row > .column {
|
||||
width: 7.14285714% !important;
|
||||
}
|
||||
.ui.grid > [class*="fifteen column"].row > .column {
|
||||
width: 6.66666667% !important;
|
||||
}
|
||||
.ui.grid > [class*="sixteen column"].row > .column {
|
||||
width: 6.25% !important;
|
||||
}
|
||||
|
||||
.ui.grid > .row > [class*="one wide"].column,
|
||||
.ui.grid > .column.row > [class*="one wide"].column,
|
||||
@@ -302,12 +185,6 @@
|
||||
.ui.column.grid > [class*="eight wide"].column {
|
||||
width: 50% !important;
|
||||
}
|
||||
.ui.grid > .row > [class*="nine wide"].column,
|
||||
.ui.grid > .column.row > [class*="nine wide"].column,
|
||||
.ui.grid > [class*="nine wide"].column,
|
||||
.ui.column.grid > [class*="nine wide"].column {
|
||||
width: 56.25% !important;
|
||||
}
|
||||
.ui.grid > .row > [class*="ten wide"].column,
|
||||
.ui.grid > .column.row > [class*="ten wide"].column,
|
||||
.ui.grid > [class*="ten wide"].column,
|
||||
@@ -338,12 +215,6 @@
|
||||
.ui.column.grid > [class*="fourteen wide"].column {
|
||||
width: 87.5% !important;
|
||||
}
|
||||
.ui.grid > .row > [class*="fifteen wide"].column,
|
||||
.ui.grid > .column.row > [class*="fifteen wide"].column,
|
||||
.ui.grid > [class*="fifteen wide"].column,
|
||||
.ui.column.grid > [class*="fifteen wide"].column {
|
||||
width: 93.75% !important;
|
||||
}
|
||||
.ui.grid > .row > [class*="sixteen wide"].column,
|
||||
.ui.grid > .column.row > [class*="sixteen wide"].column,
|
||||
.ui.grid > [class*="sixteen wide"].column,
|
||||
@@ -352,14 +223,12 @@
|
||||
}
|
||||
|
||||
.ui.centered.grid,
|
||||
.ui.centered.grid > .row,
|
||||
.ui.grid > .centered.row {
|
||||
.ui.centered.grid > .row {
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ui.centered.grid > .column:not(.aligned):not(.justified):not(.row),
|
||||
.ui.centered.grid > .row > .column:not(.aligned):not(.justified),
|
||||
.ui.grid .centered.row > .column:not(.aligned):not(.justified) {
|
||||
.ui.centered.grid > .row > .column:not(.aligned):not(.justified) {
|
||||
text-align: left;
|
||||
}
|
||||
.ui.grid > .centered.column,
|
||||
@@ -369,12 +238,6 @@
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.ui.relaxed.grid > .column:not(.row),
|
||||
.ui.relaxed.grid > .row > .column,
|
||||
.ui.grid > .relaxed.row > .column {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.ui[class*="very relaxed"].grid > .column:not(.row),
|
||||
.ui[class*="very relaxed"].grid > .row > .column,
|
||||
.ui.grid > [class*="very relaxed"].row > .column {
|
||||
@@ -382,11 +245,6 @@
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.ui.relaxed.grid .row + .ui.divider,
|
||||
.ui.grid .relaxed.row + .ui.divider {
|
||||
margin-left: 1.5rem;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
.ui[class*="very relaxed"].grid .row + .ui.divider,
|
||||
.ui.grid [class*="very relaxed"].row + .ui.divider {
|
||||
margin-left: 2.5rem;
|
||||
@@ -439,23 +297,3 @@
|
||||
margin-right: -1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ui.ui.ui.compact.grid > .column:not(.row),
|
||||
.ui.ui.ui.compact.grid > .row > .column {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.ui.ui.ui.compact.grid > * {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.ui.ui.ui.compact.grid > .row {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ui.ui.ui.compact.grid > .column:not(.row) {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
@@ -24,10 +24,6 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ui.header > .ui.label.compact {
|
||||
margin-top: inherit;
|
||||
}
|
||||
|
||||
.ui.header .sub.header {
|
||||
display: block;
|
||||
font-weight: var(--font-weight-normal);
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
.ui.input.error > input {
|
||||
background: var(--color-error-bg);
|
||||
border-color: var(--color-error-border);
|
||||
color: var(--color-error-text);
|
||||
color: var(--color-input-text);
|
||||
}
|
||||
|
||||
.ui.icon.input > i.icon {
|
||||
@@ -69,42 +69,24 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ui.ui.ui.ui.icon.input > textarea,
|
||||
.ui.ui.ui.ui.icon.input > input {
|
||||
padding-right: 2.67142857em;
|
||||
}
|
||||
.ui.icon.input > i.link.icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui.icon.input > i.circular.icon {
|
||||
top: 0.35em;
|
||||
right: 0.5em;
|
||||
}
|
||||
|
||||
.ui[class*="left icon"].input > i.icon {
|
||||
right: auto;
|
||||
left: 1px;
|
||||
border-radius: 0.28571429rem 0 0 0.28571429rem;
|
||||
}
|
||||
.ui[class*="left icon"].input > i.circular.icon {
|
||||
right: auto;
|
||||
left: 0.5em;
|
||||
}
|
||||
.ui.ui.ui.ui[class*="left icon"].input > textarea,
|
||||
.ui.ui.ui.ui[class*="left icon"].input > input {
|
||||
padding-left: 2.67142857em;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.ui.icon.input > textarea:focus ~ .icon,
|
||||
.ui.icon.input > input:focus ~ .icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ui.icon.input > textarea ~ i.icon {
|
||||
height: 3em;
|
||||
}
|
||||
|
||||
.ui.form .field.error > .ui.action.input > .ui.button,
|
||||
.ui.action.input.error > .ui.button {
|
||||
border-top: 1px solid var(--color-error-border);
|
||||
|
||||
@@ -184,12 +184,6 @@ a.ui.ui.ui.basic.yellow.label:hover {
|
||||
border-color: var(--color-yellow-dark-1);
|
||||
color: var(--color-yellow-dark-1);
|
||||
}
|
||||
.ui.ui.ui.olive.label {
|
||||
background: var(--color-olive);
|
||||
border-color: var(--color-olive);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.ui.ui.ui.green.label {
|
||||
background: var(--color-green);
|
||||
border-color: var(--color-green);
|
||||
@@ -221,16 +215,6 @@ a.ui.ui.ui.purple.label:hover {
|
||||
border-color: var(--color-purple-dark-1);
|
||||
color: var(--color-white);
|
||||
}
|
||||
.ui.ui.ui.basic.purple.label {
|
||||
background: transparent;
|
||||
border-color: var(--color-purple);
|
||||
color: var(--color-purple);
|
||||
}
|
||||
a.ui.ui.ui.basic.purple.label:hover {
|
||||
background: transparent;
|
||||
border-color: var(--color-purple-dark-1);
|
||||
color: var(--color-purple-dark-1);
|
||||
}
|
||||
|
||||
.ui.ui.ui.grey.label {
|
||||
background: var(--color-label-bg);
|
||||
@@ -242,16 +226,6 @@ a.ui.ui.ui.grey.label:hover {
|
||||
border-color: var(--color-label-hover-bg);
|
||||
color: var(--color-white);
|
||||
}
|
||||
.ui.ui.ui.basic.grey.label {
|
||||
background: transparent;
|
||||
border-color: var(--color-label-bg);
|
||||
color: var(--color-label-text);
|
||||
}
|
||||
a.ui.ui.ui.basic.grey.label:hover {
|
||||
background: transparent;
|
||||
border-color: var(--color-label-hover-bg);
|
||||
color: var(--color-label-hover-bg);
|
||||
}
|
||||
|
||||
/* "horizontal label" is actually "fat label" which has enough padding spaces to be used standalone in headers */
|
||||
.ui.horizontal.label {
|
||||
|
||||
@@ -173,10 +173,6 @@
|
||||
margin-top: 0.35714286em;
|
||||
}
|
||||
|
||||
.ui.menu .pointing.dropdown.item .menu {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
.ui.menu .item > .label:not(.floating) {
|
||||
margin-left: 1em;
|
||||
padding: 0.3em 0.78571429em;
|
||||
@@ -188,9 +184,6 @@
|
||||
float: right;
|
||||
text-align: center;
|
||||
}
|
||||
.ui.menu .item > .floating.label {
|
||||
padding: 0.3em 0.78571429em;
|
||||
}
|
||||
.ui.menu .item > .label {
|
||||
background: var(--color-label-bg);
|
||||
color: var(--color-label-text);
|
||||
@@ -266,22 +259,12 @@
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.ui.menu:not(.vertical) .center.item {
|
||||
display: flex;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
.ui.menu .right.item::before,
|
||||
.ui.menu .right.menu > .item::before {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.ui.menu .center.item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui.vertical.menu {
|
||||
display: block;
|
||||
flex-direction: column;
|
||||
@@ -381,9 +364,6 @@
|
||||
background: none transparent;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
}
|
||||
.ui.tabular.fluid.menu {
|
||||
width: calc(100% + 2px) !important;
|
||||
}
|
||||
.ui.tabular.menu .item {
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
@@ -523,14 +503,6 @@
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
.ui.secondary.item.menu {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
.ui.secondary.item.menu .item:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.ui.vertical.secondary.menu .item:not(.dropdown) > .menu {
|
||||
margin: 0 -0.92857143em;
|
||||
}
|
||||
@@ -624,10 +596,6 @@
|
||||
.ui.stackable.menu .right.item {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.ui.stackable.menu .center.item {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.ui.stackable.menu .right.menu,
|
||||
.ui.stackable.menu .left.menu {
|
||||
flex-direction: column;
|
||||
@@ -635,8 +603,7 @@
|
||||
}
|
||||
|
||||
.ui.borderless.menu .item::before,
|
||||
.ui.borderless.menu .item .menu .item::before,
|
||||
.ui.menu .borderless.item::before {
|
||||
.ui.borderless.menu .item .menu .item::before {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
@@ -646,19 +613,12 @@
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ui.compact.vertical.menu {
|
||||
display: inline-block;
|
||||
width: auto !important;
|
||||
}
|
||||
.ui.compact.menu:not(.secondary) .item:last-child {
|
||||
border-radius: 0 0.28571429rem 0.28571429rem 0;
|
||||
}
|
||||
.ui.compact.menu .item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
.ui.compact.vertical.menu .item:last-child::before {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ui.menu.fluid,
|
||||
.ui.vertical.menu.fluid {
|
||||
@@ -675,9 +635,6 @@
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ui.attached.item.menu:not(.tabular) {
|
||||
margin: 0 -1px !important;
|
||||
}
|
||||
.ui.item.menu .item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,25 @@
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
details.ui.message {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
details.ui.message summary {
|
||||
padding: 1em 1.5em;
|
||||
}
|
||||
|
||||
details.ui.message pre {
|
||||
margin: -1.25em 0 0;
|
||||
padding: 0.5em 1.5em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
details.ui.message:not(:has(pre)) summary {
|
||||
list-style: none;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.ui.message:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -51,7 +70,6 @@
|
||||
.ui.blue.message,
|
||||
.ui.attached.blue.message {
|
||||
background: var(--color-info-bg);
|
||||
color: var(--color-info-text);
|
||||
border-color: var(--color-info-border);
|
||||
}
|
||||
|
||||
@@ -60,27 +78,21 @@
|
||||
.ui.positive.message,
|
||||
.ui.attached.positive.message {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success-text);
|
||||
border-color: var(--color-success-border);
|
||||
}
|
||||
|
||||
.ui.error.message,
|
||||
.ui.attached.error.message,
|
||||
.ui.red.message,
|
||||
.ui.attached.red.message,
|
||||
.ui.negative.message,
|
||||
.ui.attached.negative.message {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-text);
|
||||
border-color: var(--color-error-border);
|
||||
}
|
||||
|
||||
.ui.warning.message,
|
||||
.ui.attached.warning.message,
|
||||
.ui.yellow.message,
|
||||
.ui.attached.yellow.message {
|
||||
.ui.attached.warning.message {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning-text);
|
||||
border-color: var(--color-warning-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -226,18 +226,10 @@
|
||||
.ui.table td.six.wide {
|
||||
width: 37.5%;
|
||||
}
|
||||
.ui.table th.seven.wide,
|
||||
.ui.table td.seven.wide {
|
||||
width: 43.75%;
|
||||
}
|
||||
.ui.table th.eight.wide,
|
||||
.ui.table td.eight.wide {
|
||||
width: 50%;
|
||||
}
|
||||
.ui.table th.nine.wide,
|
||||
.ui.table td.nine.wide {
|
||||
width: 56.25%;
|
||||
}
|
||||
.ui.table th.ten.wide,
|
||||
.ui.table td.ten.wide {
|
||||
width: 62.5%;
|
||||
@@ -246,26 +238,6 @@
|
||||
.ui.table td.eleven.wide {
|
||||
width: 68.75%;
|
||||
}
|
||||
.ui.table th.twelve.wide,
|
||||
.ui.table td.twelve.wide {
|
||||
width: 75%;
|
||||
}
|
||||
.ui.table th.thirteen.wide,
|
||||
.ui.table td.thirteen.wide {
|
||||
width: 81.25%;
|
||||
}
|
||||
.ui.table th.fourteen.wide,
|
||||
.ui.table td.fourteen.wide {
|
||||
width: 87.5%;
|
||||
}
|
||||
.ui.table th.fifteen.wide,
|
||||
.ui.table td.fifteen.wide {
|
||||
width: 93.75%;
|
||||
}
|
||||
.ui.table th.sixteen.wide,
|
||||
.ui.table td.sixteen.wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui.basic.table {
|
||||
background: transparent;
|
||||
|
||||
@@ -31,21 +31,6 @@
|
||||
color: var(--color-text-light-3);
|
||||
}
|
||||
|
||||
.organization.invite #invite-box {
|
||||
margin: 50px auto auto;
|
||||
width: 500px !important;
|
||||
}
|
||||
|
||||
.organization.invite #invite-box #search-user-box input {
|
||||
margin-left: 0;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.organization.invite #invite-box .ui.button {
|
||||
margin-left: 5px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
.organization.invite .ui.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -199,18 +199,19 @@ td .commit-summary {
|
||||
}
|
||||
|
||||
.repo-editor-header {
|
||||
/* it should match ".repo-button-row" so the tree toggle button stays aligned */
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
margin: 1rem 0;
|
||||
padding: 3px 0;
|
||||
width: 100%;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.repo-editor-header input {
|
||||
vertical-align: middle !important;
|
||||
width: auto !important;
|
||||
padding: 7px 8px !important;
|
||||
height: 30px !important;
|
||||
padding: 5px 8px !important;
|
||||
margin-right: 5px !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -163,16 +163,19 @@ gitea-theme-meta-info {
|
||||
--color-error-bg: #322226;
|
||||
--color-error-bg-active: #49262a;
|
||||
--color-error-bg-hover: #3c2427;
|
||||
--color-error-text: var(--color-text);
|
||||
--color-error-text: #f85149;
|
||||
--color-success-border: #225633;
|
||||
--color-success-bg: #1c3329;
|
||||
--color-success-text: var(--color-text);
|
||||
--color-success-text: #3fb950;
|
||||
--color-warning-border: #5f481a;
|
||||
--color-warning-bg: #342e1f;
|
||||
--color-warning-text: var(--color-text);
|
||||
--color-warning-text: #d29922;
|
||||
--color-info-border: #254a7e;
|
||||
--color-info-bg: #1b283a;
|
||||
--color-info-text: var(--color-text);
|
||||
--color-info-text: #2f81f7;
|
||||
--color-priority-border: #4a268d;
|
||||
--color-priority-bg: #251c39;
|
||||
--color-priority-text: #a371f7;
|
||||
--color-red-badge: #db2828;
|
||||
--color-red-badge-bg: #db28281a;
|
||||
--color-red-badge-hover-bg: #db28284d;
|
||||
|
||||
@@ -163,16 +163,19 @@ gitea-theme-meta-info {
|
||||
--color-error-bg: #ffebe9;
|
||||
--color-error-bg-active: #ffcecb;
|
||||
--color-error-bg-hover: #ffdcd7;
|
||||
--color-error-text: var(--color-text);
|
||||
--color-error-text: #d1242f;
|
||||
--color-success-border: #4ac26b66;
|
||||
--color-success-bg: #dafbe1;
|
||||
--color-success-text: var(--color-text);
|
||||
--color-success-text: #1a7f37;
|
||||
--color-warning-border: #d4a72c66;
|
||||
--color-warning-bg: #fff8c5;
|
||||
--color-warning-text: var(--color-text);
|
||||
--color-warning-text: #9a6700;
|
||||
--color-info-border: #54aeff66;
|
||||
--color-info-bg: #ddf4ff;
|
||||
--color-info-text: var(--color-text);
|
||||
--color-info-text: #0969da;
|
||||
--color-priority-border: #b9a1ff66;
|
||||
--color-priority-bg: #f3e7ff;
|
||||
--color-priority-text: #8250df;
|
||||
--color-red-badge: #db2828;
|
||||
--color-red-badge-bg: #db28281a;
|
||||
--color-red-badge-hover-bg: #db28284d;
|
||||
|
||||
@@ -3,17 +3,19 @@ import {normalizeTestHtml} from '../utils/testhelper.ts';
|
||||
|
||||
describe('buildArtifactTooltipHtml', () => {
|
||||
test('active artifact', () => {
|
||||
const expiresUnix = Date.UTC(2026, 2, 20, 12, 0, 0) / 1000;
|
||||
const expiresLocal = new Date(expiresUnix * 1000).toLocaleString();
|
||||
const result = buildArtifactTooltipHtml({
|
||||
name: 'artifact.zip',
|
||||
size: 1024 * 1024,
|
||||
status: 'completed',
|
||||
expiresUnix: Date.UTC(2026, 2, 20, 12, 0, 0) / 1000,
|
||||
expiresUnix,
|
||||
}, 'Expires at %s (extra)');
|
||||
|
||||
expect(normalizeTestHtml(result)).toBe(normalizeTestHtml(`<span class="flex-text-inline">
|
||||
<span>Expires at </span>
|
||||
<relative-time datetime="2026-03-20T12:00:00.000Z" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
|
||||
2026-03-20T12:00:00.000Z
|
||||
<relative-time datetime="${expiresUnix}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
|
||||
${expiresLocal}
|
||||
</relative-time>
|
||||
<span> (extra)</span>
|
||||
<span class="inline-divider">,</span>
|
||||
|
||||
@@ -7,15 +7,14 @@ export function buildArtifactTooltipHtml(artifact: ActionsArtifact, expiresAtLoc
|
||||
if (artifact.expiresUnix <= 0) {
|
||||
return html`<span class="flex-text-inline">${sizeText}</span>`; // use the same layout as below
|
||||
}
|
||||
|
||||
const datetimeLocal = new Date(artifact.expiresUnix * 1000).toLocaleString();
|
||||
// split so the <relative-time> element can be interleaved, e.g. "Expires at %s" -> ["Expires at ", ""]
|
||||
const [prefix, suffix = ''] = expiresAtLocale.split('%s');
|
||||
const datetime = new Date(artifact.expiresUnix * 1000).toISOString();
|
||||
return html`
|
||||
<span class="flex-text-inline">
|
||||
<span>${prefix}</span>
|
||||
<relative-time datetime="${datetime}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
|
||||
${datetime}
|
||||
<relative-time datetime="${artifact.expiresUnix}" threshold="P0Y" prefix="" weekday="" year="numeric" month="short" hour="numeric" minute="2-digit">
|
||||
${datetimeLocal}
|
||||
</relative-time>
|
||||
<span>${suffix}</span>
|
||||
<span class="inline-divider">,</span>
|
||||
|
||||
@@ -77,9 +77,8 @@ defineOptions({
|
||||
|
||||
const props = defineProps<{
|
||||
store: ActionRunViewStore,
|
||||
runId: number;
|
||||
jobId: number;
|
||||
actionsUrl: string;
|
||||
actionsViewUrl: string;
|
||||
locale: Record<string, any>;
|
||||
}>();
|
||||
const store = props.store;
|
||||
@@ -270,8 +269,7 @@ async function fetchJobData(abortController: AbortController): Promise<JobData>
|
||||
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
|
||||
return {step: idx, cursor: it.cursor, expanded: it.expanded};
|
||||
});
|
||||
const url = `${props.actionsUrl}/runs/${props.runId}/jobs/${props.jobId}`;
|
||||
const resp = await POST(url, {
|
||||
const resp = await POST(props.actionsViewUrl, {
|
||||
signal: abortController.signal,
|
||||
data: {logCursors},
|
||||
});
|
||||
@@ -663,6 +661,14 @@ async function hashChangeListener() {
|
||||
background: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
.job-step-logs .log-line-notice {
|
||||
background: var(--color-info-bg);
|
||||
}
|
||||
|
||||
.job-step-logs .log-line-debug {
|
||||
background: var(--color-secondary-alpha-30);
|
||||
}
|
||||
|
||||
.job-step-logs .log-cmd-error > .log-msg-label {
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
@@ -671,7 +677,11 @@ async function hashChangeListener() {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.job-step-logs .log-cmd-debug {
|
||||
.job-step-logs .log-cmd-notice > .log-msg-label {
|
||||
color: var(--color-info-text);
|
||||
}
|
||||
|
||||
.job-step-logs .log-cmd-debug > .log-msg-label {
|
||||
color: var(--color-violet);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,18 @@ const props = defineProps<{
|
||||
locale: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const locale = props.locale;
|
||||
const {currentRun: run} = toRefs(props.store.viewData);
|
||||
|
||||
const runTriggeredAtIso = computed(() => {
|
||||
const t = props.store.viewData.currentRun.triggeredAt;
|
||||
return t ? new Date(t * 1000).toISOString() : '';
|
||||
const isRerun = computed(() => run.value.runAttempt > 1);
|
||||
|
||||
const triggerUser = computed(() => {
|
||||
const currentAttempt = run.value.attempts.find((attempt) => attempt.current);
|
||||
if (currentAttempt) {
|
||||
return {name: currentAttempt.triggerUserName, link: currentAttempt.triggerUserLink};
|
||||
}
|
||||
const pusher = run.value.commit.pusher;
|
||||
return pusher.displayName ? {name: pusher.displayName, link: pusher.link} : null;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -32,7 +39,14 @@ onBeforeUnmount(() => {
|
||||
<div class="action-run-summary-view">
|
||||
<div class="action-run-summary-block">
|
||||
<div class="flex-text-block">
|
||||
{{ locale.triggeredVia.replace('%s', run.triggerEvent) }} • <relative-time :datetime="runTriggeredAtIso" prefix=""/>
|
||||
<span>{{ isRerun ? locale.rerun : locale.triggeredVia.replace('%s', run.triggerEvent) }}</span>
|
||||
<template v-if="triggerUser">
|
||||
<span>•</span>
|
||||
<a v-if="triggerUser.link" class="muted" :href="triggerUser.link">{{ triggerUser.name }}</a>
|
||||
<span v-else class="muted">{{ triggerUser.name }}</span>
|
||||
</template>
|
||||
<span>•</span>
|
||||
<relative-time :datetime="run.triggeredAt || ''" prefix=""/>
|
||||
</div>
|
||||
<div class="flex-text-block">
|
||||
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="16"/>
|
||||
|
||||
@@ -91,6 +91,7 @@ export function createEmptyActionsRun(): ActionsRun {
|
||||
return {
|
||||
repoId: 0,
|
||||
link: '',
|
||||
viewLink: '',
|
||||
title: '',
|
||||
titleHTML: '',
|
||||
status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
|
||||
@@ -103,6 +104,8 @@ export function createEmptyActionsRun(): ActionsRun {
|
||||
workflowID: '',
|
||||
workflowLink: '',
|
||||
isSchedule: false,
|
||||
runAttempt: 0,
|
||||
attempts: [],
|
||||
duration: '',
|
||||
triggeredAt: 0,
|
||||
triggerEvent: '',
|
||||
@@ -125,7 +128,7 @@ export function createEmptyActionsRun(): ActionsRun {
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionRunViewStore(actionsUrl: string, runId: number) {
|
||||
export function createActionRunViewStore(viewUrl: string) {
|
||||
let loadingAbortController: AbortController | null = null;
|
||||
let intervalID: IntervalId | null = null;
|
||||
const viewData = reactive({
|
||||
@@ -137,8 +140,7 @@ export function createActionRunViewStore(actionsUrl: string, runId: number) {
|
||||
const abortController = new AbortController();
|
||||
loadingAbortController = abortController;
|
||||
try {
|
||||
const url = `${actionsUrl}/runs/${runId}`;
|
||||
const resp = await POST(url, {signal: abortController.signal, data: {}});
|
||||
const resp = await POST(viewUrl, {signal: abortController.signal, data: {}});
|
||||
const runResp = await resp.json();
|
||||
if (loadingAbortController !== abortController) return;
|
||||
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
|
||||
import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
|
||||
import {onMounted, shallowRef} from 'vue';
|
||||
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
|
||||
import {computed, onBeforeUnmount, onMounted} from 'vue';
|
||||
import tippy, {createSingleton} from 'tippy.js';
|
||||
import type {CreateSingletonInstance, Instance} from 'tippy.js';
|
||||
|
||||
defineProps<{
|
||||
type HeatmapValue = {date: Date; count: number};
|
||||
type HeatmapCell = {date: Date; colorIndex: number; ariaLabel: string; tooltip: string};
|
||||
type MonthLabel = {monthIdx: number; weekIdx: number};
|
||||
|
||||
const props = defineProps<{
|
||||
values: HeatmapValue[];
|
||||
locale: {
|
||||
textTotalContributions: string;
|
||||
heatMapLocale: Partial<HeatmapLocale>;
|
||||
heatMapLocale: {months: string[]; days: string[]; on: string; more: string; less: string};
|
||||
noDataText: string;
|
||||
tooltipUnit: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const colorRange = [
|
||||
'var(--color-secondary-alpha-60)',
|
||||
'var(--color-secondary-alpha-60)',
|
||||
'var(--color-primary-light-4)',
|
||||
'var(--color-primary-light-2)',
|
||||
@@ -24,21 +26,112 @@ const colorRange = [
|
||||
'var(--color-primary-dark-4)',
|
||||
];
|
||||
|
||||
const endDate = shallowRef(new Date());
|
||||
const squareSize = 10;
|
||||
const squareBorder = 2;
|
||||
const cellSize = squareSize + squareBorder;
|
||||
const daysInWeek = 7;
|
||||
const trailingDays = 365;
|
||||
const gridLeft = Math.ceil(squareSize * 2.5);
|
||||
const gridTop = squareSize + squareSize / 2;
|
||||
|
||||
onMounted(() => {
|
||||
// work around issue with first legend color being rendered twice and legend cut off
|
||||
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper')!;
|
||||
legend.setAttribute('viewBox', '12 0 80 10');
|
||||
legend.style.marginRight = '-12px';
|
||||
const now = new Date();
|
||||
|
||||
function dateKey(d: Date): string {
|
||||
return `${d.getFullYear()}${String(d.getMonth()).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function shiftDate(d: Date, days: number): Date {
|
||||
const out = new Date(d);
|
||||
out.setDate(out.getDate() + days);
|
||||
return out;
|
||||
}
|
||||
|
||||
const grid = computed(() => {
|
||||
const start = shiftDate(now, -trailingDays);
|
||||
const padStart = start.getDay();
|
||||
const padEnd = daysInWeek - 1 - now.getDay();
|
||||
const weekCount = (trailingDays + 1 + padStart + padEnd) / daysInWeek;
|
||||
|
||||
const maxCount = props.values.length ? Math.max(...props.values.map((v) => v.count)) : 0;
|
||||
const max = maxCount > 0 ? Math.ceil(maxCount / 5 * 4) : 1;
|
||||
|
||||
const activities = new Map<string, {count: number; colorIndex: number}>();
|
||||
for (const {date, count} of props.values) {
|
||||
const colorIndex = count >= max ? 4 : Math.max(1, Math.ceil((count / max) * 3));
|
||||
activities.set(dateKey(date), {count, colorIndex});
|
||||
}
|
||||
|
||||
const {months, on} = props.locale.heatMapLocale;
|
||||
const {noDataText, tooltipUnit} = props.locale;
|
||||
|
||||
const cursorStart = shiftDate(start, -padStart);
|
||||
const cursor = new Date(cursorStart.getFullYear(), cursorStart.getMonth(), cursorStart.getDate());
|
||||
const calendar: HeatmapCell[][] = [];
|
||||
for (let w = 0; w < weekCount; w++) {
|
||||
const week: HeatmapCell[] = [];
|
||||
for (let d = 0; d < daysInWeek; d++) {
|
||||
const hit = activities.get(dateKey(cursor));
|
||||
const dateStr = `${months[cursor.getMonth()]} ${cursor.getDate()}, ${cursor.getFullYear()}`;
|
||||
const head = hit ? `${hit.count} ${tooltipUnit}` : noDataText;
|
||||
week.push({
|
||||
date: new Date(cursor),
|
||||
colorIndex: hit ? hit.colorIndex : 0,
|
||||
ariaLabel: `${head} ${on} ${dateStr}`,
|
||||
tooltip: `<b>${head}</b> ${on} ${dateStr}`,
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
calendar.push(week);
|
||||
}
|
||||
|
||||
const monthLabels: MonthLabel[] = [];
|
||||
for (let w = 1; w < calendar.length; w++) {
|
||||
const prev = calendar[w - 1][0].date;
|
||||
const curr = calendar[w][0].date;
|
||||
if (prev.getMonth() !== curr.getMonth()) {
|
||||
monthLabels.push({monthIdx: curr.getMonth(), weekIdx: w});
|
||||
}
|
||||
}
|
||||
|
||||
const width = gridLeft + (cellSize * weekCount) + squareBorder;
|
||||
const height = gridTop + (cellSize * daysInWeek);
|
||||
return {calendar, monthLabels, width, height};
|
||||
});
|
||||
|
||||
function handleDayClick(e: Event & {date: Date}) {
|
||||
// Reset filter if same date is clicked
|
||||
const legendViewBox = `${cellSize} 0 ${squareSize * (colorRange.length + 2)} ${squareSize}`;
|
||||
|
||||
const cellInstances = new Map<Element, Instance>();
|
||||
let singleton: CreateSingletonInstance | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
singleton = createSingleton([], {
|
||||
overrides: [],
|
||||
moveTransition: 'transform 0.1s ease-out',
|
||||
allowHTML: true,
|
||||
theme: 'tooltip',
|
||||
role: 'tooltip',
|
||||
placement: 'top',
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
singleton?.destroy();
|
||||
for (const instance of cellInstances.values()) instance.destroy();
|
||||
cellInstances.clear();
|
||||
});
|
||||
|
||||
function lazyInitTooltip(e: MouseEvent) {
|
||||
const el = e.target as Element;
|
||||
if (!singleton || cellInstances.has(el) || !el.classList.contains('heatmap-day')) return;
|
||||
cellInstances.set(el, tippy(el, {content: el.getAttribute('data-tooltip')!}));
|
||||
singleton.setInstances([...cellInstances.values()]);
|
||||
}
|
||||
|
||||
function handleDayClick(date: Date) {
|
||||
const params = new URLSearchParams(document.location.search);
|
||||
const queryDate = params.get('date');
|
||||
// Timezone has to be stripped because toISOString() converts to UTC
|
||||
const clickedDate = new Date(e.date.getTime() - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
|
||||
const clickedDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
|
||||
|
||||
if (queryDate && queryDate === clickedDate) {
|
||||
params.delete('date');
|
||||
@@ -53,16 +146,63 @@ function handleDayClick(e: Event & {date: Date}) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<calendar-heatmap
|
||||
:locale="locale.heatMapLocale"
|
||||
:no-data-text="locale.noDataText"
|
||||
:tooltip-unit="locale.tooltipUnit"
|
||||
:end-date="endDate"
|
||||
:values="values"
|
||||
:range-color="colorRange"
|
||||
@day-click="handleDayClick($event)"
|
||||
:tippy-props="{theme: 'tooltip'}"
|
||||
>
|
||||
<template #vch__legend-left>{{ locale.textTotalContributions }}</template>
|
||||
</calendar-heatmap>
|
||||
<div>
|
||||
<svg class="heatmap-svg" :viewBox="`0 0 ${grid.width} ${grid.height}`">
|
||||
<g class="heatmap-month-labels" :transform="`translate(${gridLeft}, 0)`">
|
||||
<text
|
||||
v-for="m in grid.monthLabels"
|
||||
:key="m.weekIdx"
|
||||
class="heatmap-month-label"
|
||||
:x="cellSize * m.weekIdx"
|
||||
:y="cellSize - squareBorder"
|
||||
>
|
||||
{{ locale.heatMapLocale.months[m.monthIdx] }}
|
||||
</text>
|
||||
</g>
|
||||
<g class="heatmap-day-labels" :transform="`translate(0, ${gridTop})`">
|
||||
<text class="heatmap-day-label" :x="0" :y="20">{{ locale.heatMapLocale.days[1] }}</text>
|
||||
<text class="heatmap-day-label" :x="0" :y="44">{{ locale.heatMapLocale.days[3] }}</text>
|
||||
<text class="heatmap-day-label" :x="0" :y="69">{{ locale.heatMapLocale.days[5] }}</text>
|
||||
</g>
|
||||
<g class="heatmap-grid" :transform="`translate(${gridLeft}, ${gridTop})`" @mouseover="lazyInitTooltip">
|
||||
<g
|
||||
v-for="(week, w) in grid.calendar"
|
||||
:key="w"
|
||||
class="heatmap-week"
|
||||
:transform="`translate(${w * cellSize}, 0)`"
|
||||
>
|
||||
<template v-for="(day, d) in week" :key="d">
|
||||
<rect
|
||||
v-if="day.date < now"
|
||||
class="heatmap-day"
|
||||
:transform="`translate(0, ${d * cellSize})`"
|
||||
:width="squareSize"
|
||||
:height="squareSize"
|
||||
:style="{fill: colorRange[day.colorIndex]}"
|
||||
:aria-label="day.ariaLabel"
|
||||
:data-tooltip="day.tooltip"
|
||||
@click="handleDayClick(day.date)"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="heatmap-footer">
|
||||
<div>{{ locale.textTotalContributions }}</div>
|
||||
<div class="heatmap-legend">
|
||||
<div>{{ locale.heatMapLocale.less }}</div>
|
||||
<svg class="heatmap-legend-svg" :viewBox="legendViewBox" :height="squareSize">
|
||||
<rect
|
||||
v-for="(color, i) in colorRange"
|
||||
:key="i"
|
||||
:width="squareSize"
|
||||
:height="squareSize"
|
||||
:x="(i + 1) * cellSize"
|
||||
:style="{fill: color}"
|
||||
/>
|
||||
</svg>
|
||||
<div>{{ locale.heatMapLocale.more }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,63 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {getIssueColorClass, getIssueIcon} from '../features/issue.ts';
|
||||
import {computed, onMounted, shallowRef} from 'vue';
|
||||
import {computed} from 'vue';
|
||||
import type {Issue} from '../types.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
repoLink: string,
|
||||
loadIssueInfoUrl: string,
|
||||
issue?: Issue | null,
|
||||
renderedLabels?: string,
|
||||
errorMessage?: string,
|
||||
}>();
|
||||
|
||||
const loading = shallowRef(false);
|
||||
const issue = shallowRef<Issue | null>(null);
|
||||
const renderedLabels = shallowRef('');
|
||||
const errorMessage = shallowRef('');
|
||||
|
||||
const createdAt = computed(() => {
|
||||
if (!issue?.value) return '';
|
||||
return new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||
if (!props.issue) return '';
|
||||
return new Date(props.issue.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||
});
|
||||
|
||||
const body = computed(() => {
|
||||
if (!issue?.value) return '';
|
||||
const body = issue.value.body.replace(/\n+/g, ' ');
|
||||
if (!props.issue) return '';
|
||||
const body = props.issue.body.replace(/\n+/g, ' ');
|
||||
return body.length > 85 ? `${body.substring(0, 85)}…` : body;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const resp = await GET(props.loadIssueInfoUrl);
|
||||
if (!resp.ok) {
|
||||
errorMessage.value = resp.status ? resp.statusText : 'Unknown network error';
|
||||
return;
|
||||
}
|
||||
const respJson = await resp.json();
|
||||
issue.value = respJson.convertedIssue;
|
||||
renderedLabels.value = respJson.renderedLabels;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw-p-4">
|
||||
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
|
||||
<div v-else-if="issue" class="tw-flex tw-flex-col tw-gap-2">
|
||||
<div v-if="issue" class="tw-flex tw-flex-col tw-gap-2">
|
||||
<div class="tw-text-12">
|
||||
<a :href="repoLink" class="muted">{{ issue.repository.full_name }}</a>
|
||||
<a :href="issue.repository.html_url" class="muted">{{ issue.repository.full_name }}</a>
|
||||
on {{ createdAt }}
|
||||
</div>
|
||||
<div class="flex-text-block">
|
||||
<svg-icon :name="getIssueIcon(issue)" :class="getIssueColorClass(issue)"/>
|
||||
<span class="issue-title tw-font-semibold tw-break-anywhere">
|
||||
<a :href="issue.html_url" class="issue-title tw-font-semibold tw-break-anywhere muted">
|
||||
{{ issue.title }}
|
||||
<span class="index">#{{ issue.number }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="body">{{ body }}</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
|
||||
@@ -230,7 +230,7 @@ function clearMergeMessage() {
|
||||
bottom: -1px;
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
color: var(--color-info-text);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-info-bg);
|
||||
border: 1px solid var(--color-info-border);
|
||||
border-left: none;
|
||||
@@ -238,7 +238,7 @@ function clearMergeMessage() {
|
||||
}
|
||||
|
||||
.auto-merge-small:hover {
|
||||
color: var(--color-info-text);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-info-bg);
|
||||
border: 1px solid var(--color-info-border);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {toRefs} from 'vue';
|
||||
import {POST, DELETE} from '../modules/fetch.ts';
|
||||
import ActionRunSummaryView from './ActionRunSummaryView.vue';
|
||||
import ActionRunJobView from './ActionRunJobView.vue';
|
||||
import type {ActionsRunAttempt} from '../modules/gitea-actions.ts';
|
||||
import {createActionRunViewStore} from './ActionRunView.ts';
|
||||
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
|
||||
|
||||
@@ -13,16 +14,28 @@ defineOptions({
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
runId: number;
|
||||
jobId: number;
|
||||
actionsUrl: string;
|
||||
actionsViewUrl: string;
|
||||
locale: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const locale = props.locale;
|
||||
const store = createActionRunViewStore(props.actionsUrl, props.runId);
|
||||
const store = createActionRunViewStore(props.actionsViewUrl);
|
||||
const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
|
||||
|
||||
function formatAttemptTitle(attempt: ActionsRunAttempt) {
|
||||
return attempt.latest ? `${locale.latestAttempt} #${attempt.attempt}` : `${locale.attempt} #${attempt.attempt}`;
|
||||
}
|
||||
|
||||
function formatCurrentAttemptTitle(attempt: ActionsRunAttempt) {
|
||||
return attempt.latest ? `${locale.latest} #${attempt.attempt}` : formatAttemptTitle(attempt);
|
||||
}
|
||||
|
||||
function buildArtifactLink(name: string) {
|
||||
const searchString = run.value.runAttempt > 0 ? `?attempt=${run.value.runAttempt}` : '';
|
||||
return `${run.value.link}/artifacts/${encodeURIComponent(name)}${searchString}`;
|
||||
}
|
||||
|
||||
function cancelRun() {
|
||||
POST(`${run.value.link}/cancel`);
|
||||
}
|
||||
@@ -33,7 +46,7 @@ function approveRun() {
|
||||
|
||||
async function deleteArtifact(name: string) {
|
||||
if (!window.confirm(locale.confirmDeleteArtifact.replace('%s', name))) return;
|
||||
await DELETE(`${run.value.link}/artifacts/${encodeURIComponent(name)}`);
|
||||
await DELETE(buildArtifactLink(name));
|
||||
await store.forceReloadCurrentRun();
|
||||
}
|
||||
</script>
|
||||
@@ -51,10 +64,10 @@ async function deleteArtifact(name: string) {
|
||||
<button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
|
||||
{{ locale.approve }}
|
||||
</button>
|
||||
<button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
|
||||
<button class="ui small compact button tw-text-red" @click="cancelRun()" v-else-if="run.canCancel">
|
||||
{{ locale.cancel }}
|
||||
</button>
|
||||
<template v-else-if="run.canRerun">
|
||||
<template v-if="run.canRerun">
|
||||
<div v-if="run.canRerunFailed" class="ui small compact buttons">
|
||||
<button class="ui basic small compact button link-action" :data-url="`${run.link}/rerun-failed`">
|
||||
{{ locale.rerun_failed }}
|
||||
@@ -72,10 +85,45 @@ async function deleteArtifact(name: string) {
|
||||
{{ locale.rerun_all }}
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="run.attempts.length > 1" class="ui dropdown basic small compact button">
|
||||
<div class="flex-text-inline">
|
||||
<SvgIcon name="octicon-history" :size="14"/>
|
||||
<span>{{ formatCurrentAttemptTitle(run.attempts.find((attempt) => attempt.current)!) }}</span>
|
||||
</div>
|
||||
<SvgIcon name="octicon-triangle-down" :size="14" class="dropdown icon"/>
|
||||
<div class="menu">
|
||||
<a
|
||||
v-for="attempt in run.attempts"
|
||||
:key="attempt.attempt"
|
||||
class="item tw-flex tw-flex-col tw-gap-2"
|
||||
:class="attempt.current ? 'selected' : ''"
|
||||
:href="attempt.link"
|
||||
>
|
||||
<div class="flex-text-block">
|
||||
<SvgIcon name="octicon-check" :size="14" :class="{'tw-invisible': !Boolean(attempt.current)}"/>
|
||||
<strong class="tw-text-sm gt-ellipsis">{{ formatAttemptTitle(attempt) }}</strong>
|
||||
</div>
|
||||
<div class="flex-text-block tw-pl-[20px]">
|
||||
<span class="flex-text-inline tw-flex-shrink-0">
|
||||
<ActionRunStatus :locale-status="locale.status[attempt.status]" :status="attempt.status" :size="14" class="flex-text-block"/>
|
||||
<span>{{ locale.status[attempt.status] }}</span>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<relative-time :datetime="attempt.triggeredAt" prefix=""/>
|
||||
<span>•</span>
|
||||
<span class="gt-ellipsis">{{ attempt.triggerUserName }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-commit-summary">
|
||||
<span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
|
||||
<span>
|
||||
<a v-if="run.workflowLink" class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>
|
||||
<b v-else>{{ run.workflowID }}</b>
|
||||
:
|
||||
</span>
|
||||
<template v-if="run.isSchedule">
|
||||
{{ locale.scheduled }}
|
||||
</template>
|
||||
@@ -94,8 +142,8 @@ async function deleteArtifact(name: string) {
|
||||
<div class="action-view-body">
|
||||
<div class="action-view-left">
|
||||
<!-- summary -->
|
||||
<a class="job-brief-item silenced" :href="run.link" :class="!props.jobId ? 'selected' : ''">
|
||||
<SvgIcon name="octicon-list-unordered"/>
|
||||
<a class="job-brief-item silenced" :href="run.viewLink" :class="!props.jobId ? 'selected' : ''">
|
||||
<SvgIcon name="octicon-home"/>
|
||||
<span class="gt-ellipsis">{{ locale.summary }}</span>
|
||||
</a>
|
||||
|
||||
@@ -105,7 +153,7 @@ async function deleteArtifact(name: string) {
|
||||
<!-- unlike other lists, the items have paddings already -->
|
||||
<ul class="ui relaxed list flex-items-block tw-p-0">
|
||||
<li class="item job-brief-item" v-for="job in run.jobs" :key="job.id" :class="props.jobId === job.id ? 'selected' : ''">
|
||||
<a class="tw-contents silenced" :href="run.link+'/jobs/'+job.id">
|
||||
<a class="tw-contents silenced" :href="job.link">
|
||||
<ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
|
||||
<span class="tw-flex-1 gt-ellipsis">{{ job.name }}</span>
|
||||
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${job.id}/rerun`" v-if="job.canRerun"/>
|
||||
@@ -123,7 +171,7 @@ async function deleteArtifact(name: string) {
|
||||
<template v-if="artifact.status !== 'expired'">
|
||||
<a
|
||||
class="tw-flex-1 flex-text-block muted" target="_blank"
|
||||
:href="run.link+'/artifacts/'+encodeURIComponent(artifact.name)"
|
||||
:href="buildArtifactLink(artifact.name)"
|
||||
:data-tooltip-content="buildArtifactTooltipHtml(artifact, locale.artifactExpiresAt)"
|
||||
data-tooltip-render="html"
|
||||
data-tooltip-placement="top-end"
|
||||
@@ -136,7 +184,7 @@ async function deleteArtifact(name: string) {
|
||||
</a>
|
||||
</template>
|
||||
<span v-else class="flex-text-block tw-flex-1 tw-text-text-light-2">
|
||||
<SvgIcon name="octicon-file"/>
|
||||
<SvgIcon name="octicon-file-removed"/>
|
||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
|
||||
</span>
|
||||
@@ -167,9 +215,8 @@ async function deleteArtifact(name: string) {
|
||||
v-else
|
||||
:store="store"
|
||||
:locale="locale"
|
||||
:run-id="props.runId"
|
||||
:actions-view-url="props.actionsViewUrl"
|
||||
:job-id="props.jobId"
|
||||
:actions-url="props.actionsUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type DayDataObject,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {onMounted, shallowRef} from 'vue';
|
||||
@@ -78,7 +79,7 @@ async function fetchGraphData() {
|
||||
errorText.value = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
errorText.value = err.message;
|
||||
errorText.value = errorMessage(err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
fillEmptyStartDaysWithZeroes,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
@@ -166,7 +167,7 @@ export default defineComponent({
|
||||
this.errorText = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
this.errorText = err.message;
|
||||
this.errorText = errorMessage(err);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type DayDataObject,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {onMounted, ref, shallowRef} from 'vue';
|
||||
@@ -74,7 +75,7 @@ async function fetchGraphData() {
|
||||
errorText.value = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
errorText.value = err.message;
|
||||
errorText.value = errorMessage(err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -83,9 +83,11 @@ const saveState = () => {
|
||||
localUserSettings.setJsonObject(settingKeyStates, Object.fromEntries(sortedStates));
|
||||
};
|
||||
|
||||
const minNodeWidth = 168;
|
||||
const maxNodeWidth = 232;
|
||||
const nodeWidth = computed(() => {
|
||||
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length));
|
||||
return Math.min(Math.max(140, maxNameLength * 8), 180);
|
||||
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length), 0);
|
||||
return Math.min(Math.max(minNodeWidth, maxNameLength * 8), maxNodeWidth);
|
||||
});
|
||||
|
||||
const horizontalSpacing = computed(() => nodeWidth.value + 84);
|
||||
@@ -342,8 +344,8 @@ const graphMetrics = computed(() => {
|
||||
};
|
||||
})
|
||||
|
||||
const nodeHeight = 48;
|
||||
const verticalSpacing = 88;
|
||||
const nodeHeight = 52;
|
||||
const verticalSpacing = 90;
|
||||
const margin = 40;
|
||||
|
||||
const minScale = 0.3;
|
||||
@@ -589,7 +591,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
:d="edge.path"
|
||||
fill="none"
|
||||
stroke="var(--color-secondary-alpha-50)"
|
||||
stroke-width="1.75"
|
||||
stroke-width="1.5"
|
||||
:class="['node-edge', { 'highlighted-edge': isEdgeHighlighted(edge) }]"
|
||||
/>
|
||||
|
||||
@@ -608,10 +610,10 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
:y="job.y"
|
||||
:width="nodeWidth"
|
||||
:height="nodeHeight"
|
||||
rx="10"
|
||||
fill="var(--color-button)"
|
||||
stroke="var(--color-light-border)"
|
||||
stroke-width="1.25"
|
||||
rx="8"
|
||||
fill="var(--color-box-body)"
|
||||
stroke="var(--color-secondary)"
|
||||
stroke-width="1"
|
||||
class="job-rect"
|
||||
/>
|
||||
|
||||
@@ -619,7 +621,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
v-if="nodesWithIncomingEdge.has(job.id)"
|
||||
:cx="job.x"
|
||||
:cy="job.y + nodeHeight / 2"
|
||||
r="6"
|
||||
r="4.5"
|
||||
class="node-port"
|
||||
/>
|
||||
|
||||
@@ -627,13 +629,13 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
v-if="nodesWithOutgoingEdge.has(job.id)"
|
||||
:cx="job.x + nodeWidth"
|
||||
:cy="job.y + nodeHeight / 2"
|
||||
r="6"
|
||||
r="4.5"
|
||||
class="node-port"
|
||||
/>
|
||||
|
||||
<foreignObject
|
||||
:x="job.x + 10"
|
||||
:y="job.y + 14"
|
||||
:y="job.y + 16"
|
||||
width="20"
|
||||
height="20"
|
||||
class="job-status-fg-obj"
|
||||
@@ -644,10 +646,10 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
</foreignObject>
|
||||
|
||||
<foreignObject
|
||||
:x="job.x + 36"
|
||||
:y="job.y"
|
||||
:width="nodeWidth - 40"
|
||||
:height="nodeHeight"
|
||||
:x="job.x + 38"
|
||||
:y="job.y + 2"
|
||||
:width="nodeWidth - 44"
|
||||
:height="nodeHeight - 4"
|
||||
>
|
||||
<div class="job-text-wrap">
|
||||
<span class="job-name">{{ job.name }}</span>
|
||||
@@ -707,7 +709,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 12px 16px 20px;
|
||||
padding: 10px 14px 18px;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
@@ -730,7 +732,7 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
}
|
||||
|
||||
.highlighted-edge {
|
||||
stroke-width: 2.25 !important;
|
||||
stroke-width: 2 !important;
|
||||
stroke: var(--color-workflow-edge-hover) !important;
|
||||
}
|
||||
|
||||
@@ -749,18 +751,20 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding-right: 8px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
padding: 4px 8px 4px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.job-name {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
user-select: none;
|
||||
@@ -768,10 +772,13 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
}
|
||||
|
||||
.job-duration {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-light-2);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -792,11 +799,13 @@ function onNodeClick(job: JobNode, event: MouseEvent) {
|
||||
.node-port {
|
||||
fill: var(--color-box-body);
|
||||
stroke: var(--color-light-border);
|
||||
stroke-width: 1.5;
|
||||
stroke-width: 1.25;
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.node-edge {
|
||||
transition: stroke-width 0.2s ease, opacity 0.2s ease;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
import {checkAppUrl} from '../common-page.ts';
|
||||
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {urlQueryEscape} from '../../utils.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {pathEscape} from '../../utils/url.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
@@ -232,7 +232,7 @@ function initAdminAuthentication() {
|
||||
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name')!;
|
||||
const onAuthNameChange = function () {
|
||||
// appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
|
||||
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${urlQueryEscape(elAuthName.value)}/callback`;
|
||||
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${pathEscape(elAuthName.value)}/callback`;
|
||||
};
|
||||
elAuthName.addEventListener('input', onAuthNameChange);
|
||||
onAuthNameChange();
|
||||
@@ -250,7 +250,7 @@ function initAdminNotice() {
|
||||
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
|
||||
const elModalDesc = detailModal.querySelector('.content pre')!;
|
||||
elModalDesc.textContent = elNoticeDesc.textContent;
|
||||
fomanticQuery(detailModal).modal('show');
|
||||
showFomanticModal(detailModal);
|
||||
}));
|
||||
|
||||
// Select actions
|
||||
|
||||
@@ -29,7 +29,7 @@ test('ConfigFormValueMapper', () => {
|
||||
mapper.fillFromSystemConfig();
|
||||
const formData = mapper.collectToFormData();
|
||||
const result: Record<string, string> = {};
|
||||
const keys = [], values = [];
|
||||
const keys: string[] = [], values: string[] = [];
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key === 'key') keys.push(value as string);
|
||||
if (key === 'value') values.push(value as string);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {showTemporaryTooltip} from '../../modules/tippy.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
||||
import {queryElems} from '../../utils/dom.ts';
|
||||
import {errorMessage} from '../../modules/errors.ts';
|
||||
import {submitFormFetchAction} from '../common-fetch-action.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
@@ -26,7 +27,7 @@ function initSystemConfigAutoCheckbox(el: HTMLInputElement) {
|
||||
const json: Record<string, any> = await resp.json();
|
||||
if (json.errorMessage) throw new Error(json.errorMessage);
|
||||
} catch (ex) {
|
||||
showTemporaryTooltip(el, ex.toString());
|
||||
showTemporaryTooltip(el, errorMessage(ex));
|
||||
el.checked = !el.checked;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
|
||||
const {pageData} = window.config;
|
||||
@@ -46,7 +47,7 @@ export async function initCitationFileCopyContent() {
|
||||
try {
|
||||
await initInputCitationValue(citationCopyApa, citationCopyBibtex);
|
||||
} catch (e) {
|
||||
console.error(`initCitationFileCopyContent error: ${e}`, e);
|
||||
console.error(`initCitationFileCopyContent error: ${errorMessage(e)}`, e);
|
||||
return;
|
||||
}
|
||||
updateUi();
|
||||
@@ -65,6 +66,6 @@ export async function initCitationFileCopyContent() {
|
||||
inputContent.select();
|
||||
});
|
||||
|
||||
fomanticQuery('#cite-repo-modal').modal('show');
|
||||
showFomanticModal(document.querySelector('#cite-repo-modal'));
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {camelize} from 'vue';
|
||||
import {applyAutoFocus} from './common-page.ts';
|
||||
|
||||
@@ -42,7 +42,7 @@ export function initGlobalDeleteButton(): void {
|
||||
}
|
||||
}
|
||||
|
||||
fomanticQuery(modal).modal({
|
||||
showFomanticModal(modal, {
|
||||
closable: false,
|
||||
onApprove: () => {
|
||||
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
|
||||
@@ -76,7 +76,7 @@ export function initGlobalDeleteButton(): void {
|
||||
modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
|
||||
return false; // prevent modal from closing automatically
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
fomanticQuery(elModal).modal('show');
|
||||
showFomanticModal(elModal);
|
||||
}
|
||||
|
||||
export function initGlobalButtons(): void {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {execPseudoSelectorCommands} from './common-fetch-action.ts';
|
||||
import {execPseudoSelectorCommands, handleFetchActionSuccessJson} from './common-fetch-action.ts';
|
||||
|
||||
test('execPseudoSelectorCommands', () => {
|
||||
window.document.body.innerHTML = `
|
||||
@@ -37,3 +37,23 @@ test('execPseudoSelectorCommands', () => {
|
||||
expect(ret.targets.length).toEqual(2);
|
||||
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('#d1 .x')));
|
||||
});
|
||||
|
||||
test('handleFetchActionSuccessJson', async () => {
|
||||
const spyAssign = vi.spyOn(window.location, 'assign').mockImplementation(() => {});
|
||||
const spyReload = vi.spyOn(window.location, 'reload').mockImplementation(() => {});
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {redirect: '/'});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(1);
|
||||
expect(spyReload).toHaveBeenCalledTimes(0);
|
||||
vi.resetAllMocks();
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {redirect: ''});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(0);
|
||||
expect(spyReload).toHaveBeenCalledTimes(1);
|
||||
vi.resetAllMocks();
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(0);
|
||||
expect(spyReload).toHaveBeenCalledTimes(1);
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {GET, request} from '../modules/fetch.ts';
|
||||
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
|
||||
import {addDelegatedEventListener, createElementFromHTML} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
|
||||
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
@@ -33,7 +34,7 @@ function fetchActionDoRedirect(redirect: string) {
|
||||
// * Also do so in development, to make sure the redirection logic is always tested by real users
|
||||
const needBackendHelp = redirect.includes('#');
|
||||
if (runModeIsProd && !needBackendHelp) {
|
||||
window.location.href = redirect;
|
||||
window.location.assign(redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,11 +66,13 @@ function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
|
||||
export async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
|
||||
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
|
||||
if (typeof respJson?.redirect === 'string') {
|
||||
fetchActionDoRedirect(respJson.redirect);
|
||||
const redirect = respJson?.redirect;
|
||||
if (typeof redirect === 'string' && redirect) {
|
||||
fetchActionDoRedirect(redirect);
|
||||
} else {
|
||||
// reserved behavior, in the future, there can be more fields to introduce more behaviors
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
@@ -90,7 +93,7 @@ async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, r
|
||||
async function handleFetchActionError(resp: Response) {
|
||||
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
|
||||
const respText = await resp.text();
|
||||
const respJson = isRespJson ? JSON.parse(await resp.text()) : null;
|
||||
const respJson = isRespJson ? JSON.parse(respText) : null;
|
||||
if (respJson?.errorMessage) {
|
||||
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
|
||||
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
|
||||
@@ -134,10 +137,10 @@ async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
|
||||
return;
|
||||
}
|
||||
await handleFetchActionError(resp);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
console.error(`Fetch action request error:`, e);
|
||||
showErrorToast(`Error: ${e.message ?? e}`);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
console.error(`Fetch action request error:`, err);
|
||||
showErrorToast(`Error: ${errorMessage(err)}`);
|
||||
}
|
||||
} finally {
|
||||
toggleLoadingIndicator(el, opt, false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showGlobalErrorMessage} from '../modules/errors.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {initTabSwitcher} from '../modules/fomantic/tab.ts';
|
||||
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
|
||||
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
|
||||
@@ -100,7 +101,7 @@ export function initGlobalDropdown() {
|
||||
}
|
||||
|
||||
export function initGlobalComponent() {
|
||||
fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab();
|
||||
registerGlobalInitFunc('initTabSwitcher', initTabSwitcher);
|
||||
registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
|
||||
registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from './EditorMarkdown.ts';
|
||||
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
|
||||
import {createTippy} from '../../modules/tippy.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {initTabSwitcher} from '../../modules/fomantic/tab.ts';
|
||||
import type EasyMDE from 'easymde';
|
||||
import {localUserSettings} from '../../modules/user-settings.ts';
|
||||
|
||||
@@ -71,26 +71,26 @@ export class ComboMarkdownEditor {
|
||||
|
||||
options: ComboMarkdownEditorOptions;
|
||||
|
||||
tabEditor: HTMLElement;
|
||||
tabPreviewer: HTMLElement;
|
||||
tabEditor?: HTMLElement;
|
||||
tabPreviewer?: HTMLElement;
|
||||
|
||||
supportEasyMDE: boolean;
|
||||
supportEasyMDE!: boolean;
|
||||
easyMDE: any;
|
||||
easyMDEToolbarActions: any;
|
||||
easyMDEToolbarDefault: any;
|
||||
|
||||
textarea: ComboMarkdownEditorTextarea;
|
||||
textareaMarkdownToolbar: HTMLElement;
|
||||
textarea!: ComboMarkdownEditorTextarea;
|
||||
textareaMarkdownToolbar!: HTMLElement;
|
||||
textareaAutosize: any;
|
||||
|
||||
buttonMonospace: HTMLButtonElement;
|
||||
buttonMonospace!: HTMLButtonElement;
|
||||
|
||||
dropzone: HTMLElement | null;
|
||||
dropzone: HTMLElement | null = null;
|
||||
attachedDropzoneInst: any;
|
||||
|
||||
previewMode: string;
|
||||
previewUrl: string;
|
||||
previewContext: string;
|
||||
previewMode!: string;
|
||||
previewUrl!: string;
|
||||
previewContext!: string;
|
||||
|
||||
constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
|
||||
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
|
||||
@@ -204,22 +204,21 @@ export class ComboMarkdownEditor {
|
||||
}
|
||||
|
||||
setupTab() {
|
||||
const tabs = this.container.querySelectorAll<HTMLElement>('.tabular.menu > .item');
|
||||
if (!tabs.length) return;
|
||||
const elTabular = this.container.querySelector('.ui.tabular');
|
||||
if (!elTabular) return;
|
||||
this.tabEditor = this.container.querySelector('[data-tab-for="markdown-writer"]')!;
|
||||
this.tabPreviewer = this.container.querySelector('[data-tab-for="markdown-previewer"]')!;
|
||||
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
|
||||
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
|
||||
|
||||
// Fomantic Tab requires the "data-tab" to be globally unique.
|
||||
// So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
|
||||
const tabIdSuffix = generateElemId();
|
||||
const tabsArr = Array.from(tabs);
|
||||
this.tabEditor = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer')!;
|
||||
this.tabPreviewer = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer')!;
|
||||
this.tabEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
|
||||
this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
|
||||
|
||||
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
|
||||
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
|
||||
panelEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
|
||||
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
|
||||
initTabSwitcher(elTabular);
|
||||
|
||||
this.tabEditor.addEventListener('click', () => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -227,8 +226,6 @@ export class ComboMarkdownEditor {
|
||||
});
|
||||
});
|
||||
|
||||
fomanticQuery(tabs).tab();
|
||||
|
||||
this.tabPreviewer.addEventListener('click', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('mode', this.previewMode);
|
||||
@@ -291,7 +288,7 @@ export class ComboMarkdownEditor {
|
||||
}
|
||||
|
||||
switchTabToEditor() {
|
||||
this.tabEditor.click();
|
||||
this.tabEditor!.click(); // when this function is called, the tab must exist
|
||||
}
|
||||
|
||||
prepareEasyMDEToolbarActions() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {svg} from '../../svg.ts';
|
||||
import {html, htmlRaw} from '../../utils/html.ts';
|
||||
import {createElementFromHTML} from '../../utils/dom.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {hideToastsAll} from '../../modules/toast.ts';
|
||||
|
||||
const {i18n} = window.config;
|
||||
@@ -32,15 +32,14 @@ export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<
|
||||
// it's fine to do so because the modal is triggered by user's explicit action, so the user should already have read the toast messages
|
||||
hideToastsAll();
|
||||
return new Promise((resolve) => {
|
||||
const $modal = fomanticQuery(modal);
|
||||
$modal.modal({
|
||||
showFomanticModal(modal, {
|
||||
onApprove() {
|
||||
resolve(true);
|
||||
},
|
||||
onHidden() {
|
||||
$modal.remove();
|
||||
modal.remove();
|
||||
resolve(false);
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {toggleElem} from '../../utils/dom.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {submitFormFetchAction} from '../common-fetch-action.ts';
|
||||
|
||||
function nameHasScope(name: string): boolean {
|
||||
@@ -65,7 +65,7 @@ export function initCompLabelEdit(pageSelector: string) {
|
||||
form.action = isEdit ? `${curPageLink}/edit` : `${curPageLink}/new`;
|
||||
toggleElem(elIsArchivedField, isEdit);
|
||||
syncModalUi();
|
||||
fomanticQuery(elModal).modal({
|
||||
showFomanticModal(elModal, {
|
||||
onApprove() {
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
@@ -74,7 +74,7 @@ export function initCompLabelEdit(pageSelector: string) {
|
||||
submitFormFetchAction(form);
|
||||
return false;
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
};
|
||||
|
||||
elModal.addEventListener('input', () => syncModalUi());
|
||||
|
||||
@@ -5,6 +5,7 @@ import {showTemporaryTooltip} from '../modules/tippy.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {isImageFile, isVideoFile} from '../utils.ts';
|
||||
import type Dropzone from '@deltablot/dropzone';
|
||||
|
||||
@@ -149,7 +150,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
|
||||
} catch (error) {
|
||||
// TODO: if listing the existing attachments failed, it should stop from operating the content or attachments,
|
||||
// otherwise the attachments might be lost.
|
||||
showErrorToast(`Failed to load attachments: ${error}`);
|
||||
showErrorToast(`Failed to load attachments: ${errorMessage(error)}`);
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {InplaceRenderPlugin} from '../render/plugin.ts';
|
||||
import {newInplacePluginPdfViewer} from '../render/plugins/inplace-pdf-viewer.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {basename} from '../utils.ts';
|
||||
|
||||
@@ -30,7 +31,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
|
||||
rendered = true;
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg = `${e}`;
|
||||
errorMsg = errorMessage(e);
|
||||
} finally {
|
||||
container.classList.remove('is-loading');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
|
||||
type ImageContext = {
|
||||
imageBefore: HTMLImageElement | undefined,
|
||||
@@ -94,15 +93,13 @@ function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageEleme
|
||||
}
|
||||
|
||||
class ImageDiff {
|
||||
containerEl: HTMLElement;
|
||||
diffContainerWidth: number;
|
||||
containerEl!: HTMLElement;
|
||||
diffContainerWidth!: number;
|
||||
|
||||
async init(containerEl: HTMLElement) {
|
||||
this.containerEl = containerEl;
|
||||
containerEl.setAttribute('data-image-diff-loaded', 'true');
|
||||
|
||||
fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab();
|
||||
|
||||
// the container may be hidden by "viewed" checkbox, so use the parent's width for reference
|
||||
this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box')!.clientWidth - 300, 100);
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {parseIssueHref} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {createApp} from 'vue';
|
||||
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
|
||||
import {addDelegatedEventListener} from '../utils/dom.ts';
|
||||
import type {Issue} from '../types.ts';
|
||||
|
||||
type IssueInfo = {
|
||||
convertedIssue: Issue,
|
||||
renderedLabels: string,
|
||||
};
|
||||
|
||||
const issueInfoCache = new Map<string, IssueInfo>();
|
||||
|
||||
async function getIssueInfo(url: string): Promise<IssueInfo> {
|
||||
if (issueInfoCache.has(url)) return issueInfoCache.get(url)!;
|
||||
const resp = await GET(url);
|
||||
if (!resp.ok) throw new Error(resp.statusText || 'Unknown network error');
|
||||
const data = await resp.json();
|
||||
issueInfoCache.set(url, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function showRefIssuePopup(link: HTMLAnchorElement) {
|
||||
const [data, {default: ContextPopup}] = await Promise.all([
|
||||
getIssueInfo(`${link.pathname}/info`),
|
||||
import('../components/ContextPopup.vue'),
|
||||
]);
|
||||
const el = document.createElement('div');
|
||||
const app = createApp(ContextPopup, {
|
||||
issue: data.convertedIssue,
|
||||
renderedLabels: data.renderedLabels,
|
||||
});
|
||||
app.mount(el);
|
||||
// suppress ancestor title like from .commit-summary to prevent double tooltip
|
||||
link.title = '';
|
||||
createTippy(link, {
|
||||
theme: 'default',
|
||||
content: el,
|
||||
trigger: 'mouseenter focus',
|
||||
placement: 'top-start',
|
||||
interactive: true,
|
||||
role: 'dialog',
|
||||
interactiveBorder: 5,
|
||||
onDestroy: () => app.unmount(),
|
||||
}).show();
|
||||
}
|
||||
|
||||
export function initRefIssueContextPopup() {
|
||||
const selector = 'a[href]:not([data-ref-issue-popup]):not(.ref-external-issue)';
|
||||
addDelegatedEventListener<HTMLAnchorElement, MouseEvent>(document, 'mouseover', selector, (link) => {
|
||||
if (!parseIssueHref(link.getAttribute('href')!).ownerName) return;
|
||||
if (!link.classList.contains('ref-issue') && !link.closest('[data-ref-issue-container]')) return;
|
||||
if (getAttachedTippyInstance(link)) return;
|
||||
link.setAttribute('data-ref-issue-popup', '');
|
||||
|
||||
// delay so a mouse passing over the link doesn't fire a fetch
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const cancel = () => {
|
||||
clearTimeout(timer);
|
||||
link.removeAttribute('data-ref-issue-popup');
|
||||
link.removeEventListener('mouseleave', cancel);
|
||||
};
|
||||
timer = setTimeout(async () => {
|
||||
link.removeEventListener('mouseleave', cancel);
|
||||
try {
|
||||
await showRefIssuePopup(link);
|
||||
} catch (err) {
|
||||
console.error('Failed to load issue info:', err);
|
||||
link.removeAttribute('data-ref-issue-popup');
|
||||
}
|
||||
}, 300);
|
||||
link.addEventListener('mouseleave', cancel);
|
||||
});
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import RepoActionView from '../components/RepoActionView.vue';
|
||||
export function initRepositoryActionView() {
|
||||
const el = document.querySelector('#repo-action-view');
|
||||
if (!el) return;
|
||||
const runId = parseInt(el.getAttribute('data-run-id')!);
|
||||
const jobId = parseInt(el.getAttribute('data-job-id')!);
|
||||
|
||||
// TODO: the parent element's full height doesn't work well now,
|
||||
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
|
||||
@@ -13,25 +11,25 @@ export function initRepositoryActionView() {
|
||||
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
|
||||
|
||||
const view = createApp(RepoActionView, {
|
||||
runId,
|
||||
jobId,
|
||||
actionsUrl: el.getAttribute('data-actions-url'),
|
||||
jobId: parseInt(el.getAttribute('data-job-id')!),
|
||||
actionsViewUrl: el.getAttribute('data-actions-view-url'),
|
||||
locale: {
|
||||
approve: el.getAttribute('data-locale-approve'),
|
||||
cancel: el.getAttribute('data-locale-cancel'),
|
||||
rerun: el.getAttribute('data-locale-rerun'),
|
||||
rerun_all: el.getAttribute('data-locale-rerun-all'),
|
||||
rerun_failed: el.getAttribute('data-locale-rerun-failed'),
|
||||
latest: el.getAttribute('data-locale-latest'),
|
||||
latestAttempt: el.getAttribute('data-locale-latest-attempt'),
|
||||
attempt: el.getAttribute('data-locale-attempt'),
|
||||
scheduled: el.getAttribute('data-locale-runs-scheduled'),
|
||||
commit: el.getAttribute('data-locale-runs-commit'),
|
||||
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
||||
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
|
||||
summary: el.getAttribute('data-locale-summary'),
|
||||
allJobs: el.getAttribute('data-locale-all-jobs'),
|
||||
triggeredVia: el.getAttribute('data-locale-triggered-via'),
|
||||
totalDuration: el.getAttribute('data-locale-total-duration'),
|
||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
||||
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
||||
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
|
||||
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
|
||||
export function initRepoBranchButton() {
|
||||
initRepoCreateBranchButton();
|
||||
@@ -18,7 +18,7 @@ function initRepoCreateBranchButton() {
|
||||
const fromSpanName = el.getAttribute('data-modal-from-span') || '#modal-create-branch-from-span';
|
||||
document.querySelector(fromSpanName)!.textContent = el.getAttribute('data-branch-from');
|
||||
|
||||
fomanticQuery(el.getAttribute('data-modal')!).modal('show');
|
||||
showFomanticModal(document.querySelector(el.getAttribute('data-modal')!));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
@@ -26,7 +27,7 @@ async function onDownloadArchive(e: Event) {
|
||||
window.location.href = el.href; // the archive is ready, start real downloading
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showErrorToast(`Failed to download the archive: ${e}`, {duration: 2500});
|
||||
showErrorToast(`Failed to download the archive: ${errorMessage(e)}`, {duration: 2500});
|
||||
} finally {
|
||||
targetLoading.classList.remove('is-loading', 'loading-icon-2px');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {initViewedCheckboxListenerFor, initExpandAndCollapseFilesButton} from '.
|
||||
import {initImageDiff} from './imagediff.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {POST, GET} from '../modules/fetch.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {invertFileFolding} from './file-fold.ts';
|
||||
@@ -85,7 +86,7 @@ function initRepoDiffConversationForm() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showErrorToast(`Submit form failed: ${error}`);
|
||||
showErrorToast(`Submit form failed: ${errorMessage(error)}`);
|
||||
} finally {
|
||||
form?.classList.remove('is-loading');
|
||||
}
|
||||
|
||||
@@ -9,13 +9,11 @@ import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.t
|
||||
import {submitFormFetchAction} from './common-fetch-action.ts';
|
||||
import {dirname} from '../utils.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
|
||||
function initEditPreviewTab(elForm: HTMLFormElement) {
|
||||
const elTabMenu = elForm.querySelector('.repo-editor-menu');
|
||||
if (!elTabMenu) return;
|
||||
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
|
||||
|
||||
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
|
||||
const elTextarea = elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea');
|
||||
|
||||
@@ -4,6 +4,7 @@ import {GET, POST} from '../modules/fetch.ts';
|
||||
import {createElementFromHTML, showElem} from '../utils/dom.ts';
|
||||
import {parseIssuePageInfo} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
|
||||
let i18nTextEdited: string;
|
||||
let i18nTextOptions: string;
|
||||
@@ -28,7 +29,6 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
</div>`);
|
||||
document.body.append(elDetailDialog);
|
||||
const elOptionsDropdown = elDetailDialog.querySelector('.ui.dropdown.dialog-header-options')!;
|
||||
const $fomanticDialog = fomanticQuery(elDetailDialog);
|
||||
const $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
|
||||
$fomanticDropdownOptions.dropdown({
|
||||
showOnFocus: false,
|
||||
@@ -46,7 +46,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
const resp = await response.json();
|
||||
|
||||
if (resp.ok) {
|
||||
$fomanticDialog.modal('hide');
|
||||
hideFomanticModal(elDetailDialog);
|
||||
} else {
|
||||
showErrorToast(resp.message);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
$fomanticDropdownOptions.dropdown('clear', true);
|
||||
},
|
||||
});
|
||||
$fomanticDialog.modal({
|
||||
showFomanticModal(elDetailDialog, {
|
||||
async onShow() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -86,9 +86,9 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
}
|
||||
},
|
||||
onHidden() {
|
||||
$fomanticDialog.remove();
|
||||
elDetailDialog.remove();
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
}
|
||||
|
||||
function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, commentId: string) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} fr
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
|
||||
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
|
||||
import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
@@ -73,7 +74,7 @@ async function tryOnEditContent(e: Event) {
|
||||
}
|
||||
comboMarkdownEditor.dropzoneSubmitReload();
|
||||
} catch (error) {
|
||||
showErrorToast(`Failed to save the content: ${error}`);
|
||||
showErrorToast(`Failed to save the content: ${errorMessage(error)}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
renderContent.classList.remove('is-loading');
|
||||
|
||||
@@ -2,6 +2,7 @@ import {updateIssuesMeta} from './repo-common.ts';
|
||||
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {confirmModal} from './comp/ConfirmModal.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
import {DELETE, POST} from '../modules/fetch.ts';
|
||||
@@ -87,7 +88,9 @@ function initRepoIssueListCheckboxes() {
|
||||
await updateIssuesMeta(url, action, issueIDs, elementId);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
showErrorToast(err.responseJSON?.error ?? err.message);
|
||||
// FIXME: this logic (including updateIssuesMeta) is not right, should refactor to our JSONError framework
|
||||
const e = err as {responseJSON?: {error: string}};
|
||||
showErrorToast(e.responseJSON?.error ?? errorMessage(err));
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
@@ -2,6 +2,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
|
||||
export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMainContent: Element) {
|
||||
@@ -42,7 +43,7 @@ export class IssueSidebarComboList {
|
||||
elDropdown: HTMLElement;
|
||||
elList: HTMLElement | null;
|
||||
elComboValue: HTMLInputElement;
|
||||
initialValues: string[];
|
||||
initialValues: string[] = [];
|
||||
container: HTMLElement;
|
||||
|
||||
elIssueMainContent: HTMLElement;
|
||||
@@ -129,7 +130,7 @@ export class IssueSidebarComboList {
|
||||
await this.reloadPagePartially();
|
||||
} catch (e) {
|
||||
console.error('Failed to update to backend', e);
|
||||
showErrorToast(`Failed to update to backend: ${e}`);
|
||||
showErrorToast(`Failed to update to backend: ${errorMessage(e)}`);
|
||||
} finally {
|
||||
this.elIssueSidebar.classList.remove('is-loading');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
|
||||
@@ -334,7 +336,7 @@ export function initRepoIssueReferenceIssue() {
|
||||
const modal = document.querySelector(modalSelector)!;
|
||||
const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]')!;
|
||||
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
|
||||
fomanticQuery(modal).modal('show');
|
||||
showFomanticModal(modal);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -425,7 +427,7 @@ export function initRepoIssueTitleEdit() {
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showErrorToast(error.message);
|
||||
showErrorToast(errorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {contrastColor} from '../utils/color.ts';
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
import {POST, request} from '../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {hideFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import type {SortableEvent} from 'sortablejs';
|
||||
import {toggleFullScreen} from '../utils.ts';
|
||||
@@ -138,7 +138,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
|
||||
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
|
||||
}
|
||||
|
||||
fomanticQuery(elModal).modal('hide');
|
||||
hideFomanticModal(elModal);
|
||||
} finally {
|
||||
elForm.classList.remove('is-loading');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
|
||||
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {hideElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {compareVersions} from 'compare-versions';
|
||||
@@ -112,7 +113,7 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
|
||||
}
|
||||
} finally {
|
||||
elModal.classList.remove('loading', 'disabled');
|
||||
fomanticQuery(elModal).modal('hide');
|
||||
hideFomanticModal(elModal);
|
||||
comboEditor.focus();
|
||||
}
|
||||
};
|
||||
@@ -139,12 +140,12 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
|
||||
}
|
||||
$dropdown.dropdown('set selected', guessPreviousReleaseTag(tagName, existingTags));
|
||||
|
||||
fomanticQuery(elModal).modal({
|
||||
showFomanticModal(elModal, {
|
||||
onApprove: () => {
|
||||
doSubmit(tagName); // don't await, need to return false to keep the modal
|
||||
return false;
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
};
|
||||
|
||||
buttonShowModal.addEventListener('click', doShowModal);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {createSortable} from '../modules/sortable.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {queryElemChildren} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
|
||||
export function initRepoSettingsBranchesDrag() {
|
||||
const protectedBranchesList = document.querySelector<HTMLElement>('#protected-branches-list');
|
||||
@@ -23,8 +24,7 @@ export function initRepoSettingsBranchesDrag() {
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = String(err);
|
||||
showErrorToast(`Failed to update branch protection rule priority:, error: ${errorMessage}`);
|
||||
showErrorToast(`Failed to update branch protection rule priority: ${errorMessage(err)}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
@@ -50,9 +50,9 @@ export async function attachTribute(element: HTMLElement) {
|
||||
|
||||
const tribute = new Tribute({
|
||||
collection: [
|
||||
emojiCollection as TributeCollection<any>,
|
||||
mentionCollection as TributeCollection<any>,
|
||||
],
|
||||
emojiCollection,
|
||||
mentionCollection,
|
||||
] as TributeCollection<any>[],
|
||||
noMatchTemplate: () => '',
|
||||
});
|
||||
tribute.attach(element);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
@@ -80,7 +81,7 @@ async function loginPasskey() {
|
||||
|
||||
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
|
||||
} catch (err) {
|
||||
webAuthnError('general', err.message);
|
||||
webAuthnError('general', errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +105,7 @@ async function login2FA() {
|
||||
await verifyAssertion(credential);
|
||||
} catch (err) {
|
||||
if (!options.publicKey.extensions?.appid) {
|
||||
webAuthnError('general', err.message);
|
||||
webAuthnError('general', errorMessage(err));
|
||||
return;
|
||||
}
|
||||
delete options.publicKey.extensions.appid;
|
||||
@@ -114,7 +115,7 @@ async function login2FA() {
|
||||
});
|
||||
await verifyAssertion(credential);
|
||||
} catch (err) {
|
||||
webAuthnError('general', err.message);
|
||||
webAuthnError('general', errorMessage(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,6 +263,6 @@ async function webAuthnRegisterRequest() {
|
||||
});
|
||||
await webauthnRegistered(credential);
|
||||
} catch (err) {
|
||||
webAuthnError('unknown', err);
|
||||
webAuthnError('unknown', errorMessage(err));
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFor
|
||||
import {callInitFunctions} from './modules/init.ts';
|
||||
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
|
||||
import {initActionsPermissionsForm} from './features/common-actions-permissions.ts';
|
||||
import {initRefIssueContextPopup} from './features/ref-issue.ts';
|
||||
import {initGlobalShortcut} from './modules/shortcut.ts';
|
||||
import {initDevtest} from './modules/devtest.ts';
|
||||
|
||||
@@ -98,6 +99,7 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initImageDiff,
|
||||
initMarkupAnchors,
|
||||
initMarkupContent,
|
||||
initRefIssueContextPopup,
|
||||
initSshKeyFormParser,
|
||||
initStopwatch,
|
||||
initTableSort,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export function displayError(el: Element, err: Error): void {
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
|
||||
export function displayError(el: Element, err: unknown): void {
|
||||
el.classList.remove('is-loading');
|
||||
const errorNode = document.createElement('pre');
|
||||
errorNode.setAttribute('class', 'ui message error markup-block-error');
|
||||
errorNode.textContent = err.message || String(err);
|
||||
errorNode.textContent = errorMessage(err);
|
||||
el.before(errorNode);
|
||||
el.setAttribute('data-render-done', 'true');
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {initMarkupRenderAsciicast} from './asciicast.ts';
|
||||
import {initMarkupTasklist} from './tasklist.ts';
|
||||
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {initExternalRenderIframe} from './render-iframe.ts';
|
||||
import {initMarkupRefIssue} from './refissue.ts';
|
||||
import {toggleElemClass} from '../utils/dom.ts';
|
||||
|
||||
// code that runs for all markup content
|
||||
@@ -26,6 +25,5 @@ export function initMarkupContent(): void {
|
||||
initMarkupCodeMermaid(el);
|
||||
initMarkupCodeMath(el);
|
||||
initMarkupRenderAsciicast(el);
|
||||
initMarkupRefIssue(el);
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {parseIssueHref} from '../utils.ts';
|
||||
import {createApp} from 'vue';
|
||||
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
|
||||
|
||||
export function initMarkupRefIssue(el: HTMLElement) {
|
||||
queryElems(el, '.ref-issue', (el) => {
|
||||
el.addEventListener('mouseenter', showMarkupRefIssuePopup);
|
||||
el.addEventListener('focus', showMarkupRefIssuePopup);
|
||||
});
|
||||
}
|
||||
|
||||
function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
|
||||
const refIssue = e.currentTarget as HTMLElement;
|
||||
if (getAttachedTippyInstance(refIssue)) return;
|
||||
if (refIssue.classList.contains('ref-external-issue')) return;
|
||||
|
||||
const issuePathInfo = parseIssueHref(refIssue.getAttribute('href')!);
|
||||
if (!issuePathInfo.ownerName) return;
|
||||
|
||||
const el = document.createElement('div');
|
||||
const onShowAsync = async () => {
|
||||
const {default: ContextPopup} = await import('../components/ContextPopup.vue');
|
||||
const view = createApp(ContextPopup, {
|
||||
// backend: GetIssueInfo
|
||||
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,
|
||||
});
|
||||
view.mount(el);
|
||||
};
|
||||
const tippy = createTippy(refIssue, {
|
||||
theme: 'default',
|
||||
content: el,
|
||||
trigger: 'mouseenter focus',
|
||||
placement: 'top-start',
|
||||
interactive: true,
|
||||
role: 'dialog',
|
||||
interactiveBorder: 5,
|
||||
// onHide() { return false }, // help to keep the popup and debug the layout
|
||||
onShow: () => { onShowAsync() },
|
||||
});
|
||||
tippy.show();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {generateElemId} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {isDarkTheme} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
|
||||
@@ -11,7 +12,7 @@ function safeRenderIframeLink(link: any): string | null {
|
||||
}
|
||||
return url.href;
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse link: ${link}, error: ${e}`);
|
||||
console.error(`Failed to parse link: ${link}, error: ${errorMessage(e)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
|
||||
import type {Toast} from './toast.ts';
|
||||
import {registerGlobalInitFunc} from './observer.ts';
|
||||
import {fomanticQuery} from './fomantic/base.ts';
|
||||
import {showFomanticModal} from './fomantic/modal.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {showGlobalErrorMessage} from './errors.ts';
|
||||
|
||||
type LevelMap = Record<string, (message: string) => Toast | null>;
|
||||
|
||||
@@ -24,7 +25,7 @@ function initDevtestPage() {
|
||||
if (modalButtons) {
|
||||
for (const el of document.querySelectorAll('.ui.modal:not([data-skip-button])')) {
|
||||
const btn = createElementFromHTML(html`<button class="ui button">${el.id}</button`);
|
||||
btn.addEventListener('click', () => fomanticQuery(el).modal('show'));
|
||||
btn.addEventListener('click', () => showFomanticModal(el));
|
||||
modalButtons.append(btn);
|
||||
}
|
||||
}
|
||||
@@ -54,4 +55,10 @@ function initDevtestPage() {
|
||||
|
||||
export function initDevtest() {
|
||||
registerGlobalInitFunc('initDevtestPage', initDevtestPage);
|
||||
registerGlobalInitFunc('initDevtestDetailsErrorMessage', () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
showGlobalErrorMessage('showGlobalErrorMessage single message', 'warning');
|
||||
showGlobalErrorMessage('showGlobalErrorMessage message with details', 'error', `detail message 1\nvery lo${'o'.repeat(200)}ng line 2\nline 3`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import {isGiteaError, showGlobalErrorMessage} from './errors.ts';
|
||||
import {isGiteaError, processWindowErrorEvent, showGlobalErrorMessage} from './errors.ts';
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div class="page-content"></div>';
|
||||
});
|
||||
|
||||
test('isGiteaError', () => {
|
||||
expect(isGiteaError('', '')).toBe(true);
|
||||
@@ -16,7 +20,6 @@ test('isGiteaError', () => {
|
||||
});
|
||||
|
||||
test('showGlobalErrorMessage', () => {
|
||||
document.body.innerHTML = '<div class="page-content"></div>';
|
||||
showGlobalErrorMessage('test msg 1');
|
||||
showGlobalErrorMessage('test msg 2');
|
||||
showGlobalErrorMessage('test msg 1'); // duplicated
|
||||
@@ -25,3 +28,21 @@ test('showGlobalErrorMessage', () => {
|
||||
expect(document.body.innerHTML).toContain('>test msg 2<');
|
||||
expect(document.querySelectorAll('.js-global-error').length).toEqual(2);
|
||||
});
|
||||
|
||||
test('processWindowErrorEvent renders stack trace in details', () => {
|
||||
const error = new Error('boom');
|
||||
error.stack = `Error: boom\n at fn (${window.location.origin}/assets/js/index.js:1:1)`;
|
||||
processWindowErrorEvent({error, type: 'error'} as ErrorEvent & PromiseRejectionEvent);
|
||||
expect(document.querySelector('.js-global-error summary')!.textContent).toContain('JavaScript error: boom');
|
||||
expect(document.querySelector('.js-global-error pre')!.textContent).toContain('/assets/js/index.js:1:1');
|
||||
});
|
||||
|
||||
test('processWindowErrorEvent falls back to message without stack', () => {
|
||||
processWindowErrorEvent({
|
||||
error: {message: 'script error'}, type: 'error',
|
||||
filename: `${window.location.origin}/assets/js/x.js`, lineno: 5, colno: 10,
|
||||
} as ErrorEvent & PromiseRejectionEvent);
|
||||
const msgText = document.querySelector('.js-global-error .ui.message')!.textContent;
|
||||
expect(msgText).toContain('JavaScript error: script error');
|
||||
expect(msgText).toContain('@ 5:10');
|
||||
});
|
||||
@@ -2,25 +2,40 @@
|
||||
import {html} from '../utils/html.ts';
|
||||
import type {Intent} from '../types.ts';
|
||||
|
||||
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
|
||||
const msgContainer = document.querySelector('.page-content') ?? document.body;
|
||||
if (!msgContainer) {
|
||||
export function errorMessage(err: unknown): string {
|
||||
return (err as Error)?.message || String(err);
|
||||
}
|
||||
|
||||
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error', details?: string) {
|
||||
const parentContainer = document.querySelector('.page-content') ?? document.body;
|
||||
if (!parentContainer) {
|
||||
alert(`${msgType}: ${msg}`);
|
||||
return;
|
||||
}
|
||||
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
|
||||
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
||||
if (!msgDiv) {
|
||||
// compact the message to a data attribute to avoid too many duplicated messages
|
||||
const msgCompact = `${msgType}-${msg.trim()}`.replace(/[^-\w\u{80}-\u{10FFFF}]+/gu, '');
|
||||
let msgContainer = parentContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
||||
if (!msgContainer) {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
|
||||
msgDiv = el.childNodes[0] as HTMLDivElement;
|
||||
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><details class="ui ${msgType} message"><summary></summary></details></div>`;
|
||||
msgContainer = el.childNodes[0] as HTMLDivElement;
|
||||
}
|
||||
|
||||
// merge duplicated messages into "the message (count)" format
|
||||
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
|
||||
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
|
||||
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
|
||||
msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
|
||||
msgContainer.prepend(msgDiv);
|
||||
const msgCount = Number(msgContainer.getAttribute(`data-global-error-msg-count`)) + 1;
|
||||
msgContainer.setAttribute(`data-global-error-msg-compact`, msgCompact);
|
||||
msgContainer.setAttribute(`data-global-error-msg-count`, msgCount.toString());
|
||||
|
||||
const msgElem = msgContainer.querySelector('details')!;
|
||||
const msgSummary = msgElem.querySelector('summary')!;
|
||||
msgSummary.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
|
||||
if (details) {
|
||||
let msgDetailsPre = msgElem.querySelector('pre');
|
||||
if (!msgDetailsPre) msgDetailsPre = document.createElement('pre');
|
||||
msgDetailsPre.textContent = details;
|
||||
msgElem.append(msgDetailsPre);
|
||||
}
|
||||
parentContainer.prepend(msgContainer);
|
||||
}
|
||||
|
||||
// Detect whether an error originated from Gitea's own scripts, not from
|
||||
@@ -49,9 +64,9 @@ export function processWindowErrorEvent({error, reason, message, type, filename,
|
||||
// Filter out errors from browser extensions or other non-Gitea scripts.
|
||||
if (!isGiteaError(filename ?? '', err?.stack ?? '')) return;
|
||||
|
||||
let msg = err?.message ?? message;
|
||||
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
|
||||
const dot = msg.endsWith('.') ? '' : '.';
|
||||
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
|
||||
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
|
||||
let msg = err?.message ?? message;
|
||||
if (!err?.stack && lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
|
||||
const dot = msg.endsWith('.') ? '' : '.';
|
||||
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`, 'error', err?.stack);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import {initAriaCheckboxPatch} from './fomantic/checkbox.ts';
|
||||
import {initAriaFormFieldPatch} from './fomantic/form.ts';
|
||||
import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
|
||||
import {initAriaModalPatch} from './fomantic/modal.ts';
|
||||
import {initFomanticTransition} from './fomantic/transition.ts';
|
||||
import {initFomanticDimmer} from './fomantic/dimmer.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {initFomanticTab} from './fomantic/tab.ts';
|
||||
|
||||
export const fomanticMobileScreen = window.matchMedia('only screen and (max-width: 767.98px)');
|
||||
|
||||
@@ -24,11 +21,8 @@ export function initGiteaFomantic() {
|
||||
|
||||
initFomanticTransition();
|
||||
initFomanticDimmer();
|
||||
initFomanticTab();
|
||||
|
||||
// Use the patches to improve accessibility, these patches are designed to be as independent as possible, make it easy to modify or remove in the future.
|
||||
initAriaCheckboxPatch();
|
||||
initAriaFormFieldPatch();
|
||||
initAriaDropdownPatch();
|
||||
initAriaModalPatch();
|
||||
}
|
||||
@@ -50,7 +50,7 @@ However, the templates still have the Fomantic-style HTML layout:
|
||||
</div>
|
||||
```
|
||||
|
||||
We call `initAriaCheckboxPatch` to link the `input` and `label` which makes clicking the
|
||||
We call `initAriaLabels` to link the `input` and `label` which makes clicking the
|
||||
label etc. work. There is still a problem: These checkboxes are not friendly to screen readers,
|
||||
so we add IDs to all the Fomantic UI checkboxes automatically by JS. If the `label` part is empty,
|
||||
then the checkbox needs to get the `aria-label` attribute manually.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {generateElemId} from '../../utils/dom.ts';
|
||||
import {generateElemId, queryElems} from '../../utils/dom.ts';
|
||||
|
||||
export function linkLabelAndInput(label: Element, input: Element) {
|
||||
function linkLabelAndInput(label: Element, input: Element) {
|
||||
const labelFor = label.getAttribute('for');
|
||||
const inputId = input.getAttribute('id');
|
||||
|
||||
@@ -13,6 +13,34 @@ export function linkLabelAndInput(label: Element, input: Element) {
|
||||
}
|
||||
}
|
||||
|
||||
function patchLabels(parent: ParentNode, containerSelector: string, labelSelector: string, inputSelector: string, marker: string) {
|
||||
// Sample layout for this function:
|
||||
// <div parent>
|
||||
// <div container><label/><input/></div>
|
||||
// <div container><label/><input/></div>
|
||||
// </div>
|
||||
//
|
||||
// OR the parent is also the container:
|
||||
// <div parent container><label/><input/></div>
|
||||
|
||||
const patchLabelContainer = (container: Element) => {
|
||||
if (container.hasAttribute(marker)) return;
|
||||
const label = container.querySelector(labelSelector);
|
||||
const input = container.querySelector(inputSelector);
|
||||
if (!label || !input) return;
|
||||
linkLabelAndInput(label, input);
|
||||
container.setAttribute(marker, 'true');
|
||||
};
|
||||
queryElems(parent, containerSelector, patchLabelContainer);
|
||||
if (parent instanceof Element && parent.matches(containerSelector)) patchLabelContainer(parent);
|
||||
}
|
||||
|
||||
// link labels and inputs in `.ui.checkbox` and `.ui.form .field` so labels are clickable and accessible
|
||||
export function initAriaLabels(container: ParentNode) {
|
||||
patchLabels(container, '.ui.checkbox', 'label', 'input', 'data-checkbox-patched');
|
||||
patchLabels(container, '.ui.form .field', ':scope > label', ':scope > input, :scope > select', 'data-field-patched');
|
||||
}
|
||||
|
||||
export function fomanticQuery(s: string | Element | NodeListOf<Element>): ReturnType<typeof $> {
|
||||
// intentionally make it only work for query selector, it isn't used for creating HTML elements (for safety)
|
||||
return typeof s === 'string' ? $(document).find(s) : $(s);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import {linkLabelAndInput} from './base.ts';
|
||||
|
||||
export function initAriaCheckboxPatch() {
|
||||
// link the label and the input element so it's clickable and accessible
|
||||
for (const el of document.querySelectorAll('.ui.checkbox')) {
|
||||
if (el.hasAttribute('data-checkbox-patched')) continue;
|
||||
const label = el.querySelector('label');
|
||||
const input = el.querySelector('input');
|
||||
if (!label || !input) continue;
|
||||
linkLabelAndInput(label, input);
|
||||
el.setAttribute('data-checkbox-patched', 'true');
|
||||
}
|
||||
}
|
||||
@@ -324,7 +324,7 @@ export function hideScopedEmptyDividers(container: Element) {
|
||||
handleScopeSwitch(itemScope);
|
||||
}
|
||||
if (!isHidden(item)) {
|
||||
curScopeVisibleItems.push(item as HTMLElement);
|
||||
curScopeVisibleItems.push(item);
|
||||
}
|
||||
}
|
||||
handleScopeSwitch('');
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import {linkLabelAndInput} from './base.ts';
|
||||
|
||||
export function initAriaFormFieldPatch() {
|
||||
// link the label and the input element so it's clickable and accessible
|
||||
for (const el of document.querySelectorAll('.ui.form .field')) {
|
||||
if (el.hasAttribute('data-field-patched')) continue;
|
||||
const label = el.querySelector(':scope > label');
|
||||
const input = el.querySelector(':scope > input, :scope > select');
|
||||
if (!label || !input) continue;
|
||||
linkLabelAndInput(label, input);
|
||||
el.setAttribute('data-field-patched', 'true');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,27 @@ import type {FomanticInitFunction} from '../../types.ts';
|
||||
import {queryElems} from '../../utils/dom.ts';
|
||||
import {hideToastsFrom} from '../toast.ts';
|
||||
|
||||
type ModalOpts = {
|
||||
closable?: boolean;
|
||||
onApprove?: (this: HTMLElement) => boolean | void;
|
||||
onShow?: (this: HTMLElement) => void | Promise<void>;
|
||||
onHide?: (this: HTMLElement) => void;
|
||||
onHidden?: (this: HTMLElement) => void;
|
||||
};
|
||||
|
||||
// thin wrapper around Fomantic's jQuery modal plugin so callers don't have to touch jQuery or fomanticQuery
|
||||
export function showFomanticModal(el: Element | null, opts: ModalOpts = {}) {
|
||||
if (!el) return;
|
||||
const $el = $(el);
|
||||
if (Object.keys(opts).length) $el.modal(opts);
|
||||
$el.modal('show');
|
||||
}
|
||||
|
||||
export function hideFomanticModal(el: Element | null) {
|
||||
if (!el) return;
|
||||
$(el).modal('hide');
|
||||
}
|
||||
|
||||
const fomanticModalFn = $.fn.modal;
|
||||
|
||||
// use our own `$.fn.modal` to patch Fomantic's modal module
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import {queryElemSiblings} from '../../utils/dom.ts';
|
||||
|
||||
export function initFomanticTab() {
|
||||
$.fn.tab = function (this: any) {
|
||||
for (const elBtn of this) {
|
||||
const tabName = elBtn.getAttribute('data-tab');
|
||||
if (!tabName) continue;
|
||||
elBtn.addEventListener('click', () => {
|
||||
const elTab = document.querySelector(`.ui.tab[data-tab="${tabName}"]`)!;
|
||||
queryElemSiblings(elTab, `.ui.tab`, (el) => el.classList.remove('active'));
|
||||
queryElemSiblings(elBtn, `[data-tab]`, (el) => el.classList.remove('active'));
|
||||
elBtn.classList.add('active');
|
||||
elTab.classList.add('active');
|
||||
});
|
||||
}
|
||||
return this;
|
||||
};
|
||||
export function initTabSwitcher(tabItemContainer: Element) {
|
||||
// Clicking a `.item[data-tab]` menu item activates the matching `.ui.tab[data-tab=...]` panel
|
||||
// This design is from Fomantic UI, and it has problems like :
|
||||
// * The panel selector is global, callers should make sure the "data-tab" values don't conflict on the same page
|
||||
const tabItems = tabItemContainer.querySelectorAll('.item[data-tab]');
|
||||
for (const elItem of tabItems) {
|
||||
const tabName = elItem.getAttribute('data-tab')!;
|
||||
elItem.addEventListener('click', () => {
|
||||
const elPanel = document.querySelector(`.ui.tab[data-tab="${tabName}"]`)!;
|
||||
queryElemSiblings(elPanel, '.ui.tab', (el) => el.classList.remove('active'));
|
||||
queryElemSiblings(elItem, '.item[data-tab]', (el) => el.classList.remove('active'));
|
||||
elItem.classList.add('active');
|
||||
elPanel.classList.add('active');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export type ActionsArtifactStatus = 'expired' | 'completed';
|
||||
export type ActionsRun = {
|
||||
repoId: number,
|
||||
link: string,
|
||||
viewLink: string,
|
||||
title: string,
|
||||
titleHTML: string,
|
||||
status: ActionsRunStatus,
|
||||
@@ -17,6 +18,8 @@ export type ActionsRun = {
|
||||
workflowID: string,
|
||||
workflowLink: string,
|
||||
isSchedule: boolean,
|
||||
runAttempt: number,
|
||||
attempts: Array<ActionsRunAttempt>,
|
||||
duration: string,
|
||||
triggeredAt: number,
|
||||
triggerEvent: string,
|
||||
@@ -38,8 +41,21 @@ export type ActionsRun = {
|
||||
},
|
||||
};
|
||||
|
||||
export type ActionsRunAttempt = {
|
||||
attempt: number;
|
||||
status: ActionsRunStatus;
|
||||
done: boolean;
|
||||
link: string;
|
||||
current: boolean;
|
||||
latest: boolean;
|
||||
triggeredAt: number;
|
||||
triggerUserName: string;
|
||||
triggerUserLink: string;
|
||||
};
|
||||
|
||||
export type ActionsJob = {
|
||||
id: number;
|
||||
link: string;
|
||||
jobId: string;
|
||||
name: string;
|
||||
status: ActionsRunStatus;
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||
import type {Promisable} from '../types.ts';
|
||||
import type {InitPerformanceTracer} from './init.ts';
|
||||
import {initAriaLabels} from './fomantic/base.ts';
|
||||
|
||||
let globalSelectorObserverInited = false;
|
||||
|
||||
type SelectorHandler = {selector: string, handler: (el: HTMLElement) => void};
|
||||
const selectorHandlers: SelectorHandler[] = [];
|
||||
type SelectorHandler<T extends Element> = {selector: string, handler: (el: T) => void};
|
||||
const selectorHandlers: SelectorHandler<Element>[] = [];
|
||||
|
||||
type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => Promisable<void>;
|
||||
const globalEventFuncs: Record<string, GlobalEventFunc<HTMLElement, Event>> = {};
|
||||
|
||||
type GlobalInitFunc<T extends HTMLElement> = (el: T) => Promisable<void>;
|
||||
const globalInitFuncs: Record<string, GlobalInitFunc<HTMLElement>> = {};
|
||||
type GlobalInitFunc<T extends Element> = (el: T) => Promisable<void>;
|
||||
const globalInitFuncs: Record<string, GlobalInitFunc<Element>> = {};
|
||||
|
||||
// It handles the global events for all `<div data-global-click="onSomeElemClick"></div>` elements.
|
||||
export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) {
|
||||
@@ -23,32 +24,32 @@ export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(
|
||||
// ATTENTION: For most cases, it's recommended to use registerGlobalInitFunc instead,
|
||||
// Because this selector-based approach is less efficient and less maintainable.
|
||||
// But if there are already a lot of elements on many pages, this selector-based approach is more convenient for exiting code.
|
||||
export function registerGlobalSelectorFunc(selector: string, handler: (el: HTMLElement) => void) {
|
||||
selectorHandlers.push({selector, handler});
|
||||
export function registerGlobalSelectorFunc<T extends Element>(selector: string, handler: (el: T) => void) {
|
||||
selectorHandlers.push({selector, handler: handler as (el: Element) => void});
|
||||
// Then initAddedElementObserver will call this handler for all existing elements after all handlers are added.
|
||||
// This approach makes the init stage only need to do one "querySelectorAll".
|
||||
if (!globalSelectorObserverInited) return;
|
||||
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
|
||||
for (const el of document.querySelectorAll<T>(selector)) {
|
||||
handler(el);
|
||||
}
|
||||
}
|
||||
|
||||
// It handles the global init functions for all `<div data-global-int="initSomeElem"></div>` elements.
|
||||
export function registerGlobalInitFunc<T extends HTMLElement>(name: string, handler: GlobalInitFunc<T>) {
|
||||
globalInitFuncs[name] = handler as GlobalInitFunc<HTMLElement>;
|
||||
globalInitFuncs[name] = handler as GlobalInitFunc<Element>;
|
||||
// The "global init" functions are managed internally and called by callGlobalInitFunc
|
||||
// They must be ready before initGlobalSelectorObserver is called.
|
||||
if (globalSelectorObserverInited) throw new Error('registerGlobalInitFunc() must be called before initGlobalSelectorObserver()');
|
||||
}
|
||||
|
||||
function callGlobalInitFunc(el: HTMLElement) {
|
||||
function callGlobalInitFunc(el: Element) {
|
||||
// TODO: GLOBAL-INIT-MULTIPLE-FUNCTIONS: maybe in the future we need to extend it to support multiple functions, for example: `data-global-init="func1 func2 func3"`
|
||||
const initFunc = el.getAttribute('data-global-init')!;
|
||||
const func = globalInitFuncs[initFunc];
|
||||
if (!func) throw new Error(`Global init function "${initFunc}" not found`);
|
||||
|
||||
// when an element node is removed and added again, it should not be re-initialized again.
|
||||
type GiteaGlobalInitElement = Partial<HTMLElement> & {_giteaGlobalInited: boolean};
|
||||
type GiteaGlobalInitElement = Partial<Element> & {_giteaGlobalInited: boolean};
|
||||
if ((el as GiteaGlobalInitElement)._giteaGlobalInited) return;
|
||||
(el as GiteaGlobalInitElement)._giteaGlobalInited = true;
|
||||
|
||||
@@ -80,20 +81,23 @@ export function initGlobalSelectorObserver(perfTracer: InitPerformanceTracer | n
|
||||
const mutation = mutationList[i];
|
||||
const len = mutation.addedNodes.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const addedNode = mutation.addedNodes[i] as HTMLElement;
|
||||
const addedNode = mutation.addedNodes[i] as ParentNode;
|
||||
if (!isDocumentFragmentOrElementNode(addedNode)) continue;
|
||||
|
||||
initAriaLabels(addedNode);
|
||||
for (const {selector, handler} of selectorHandlers) {
|
||||
if (addedNode.matches(selector)) {
|
||||
if ((addedNode instanceof Element) && addedNode.matches(selector)) {
|
||||
handler(addedNode);
|
||||
}
|
||||
for (const el of addedNode.querySelectorAll<HTMLElement>(selector)) {
|
||||
for (const el of addedNode.querySelectorAll?.<HTMLElement>(selector) ?? []) {
|
||||
handler(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
initAriaLabels(document);
|
||||
if (perfTracer) {
|
||||
for (const {selector, handler} of selectorHandlers) {
|
||||
perfTracer.recordCall(`initGlobalSelectorObserver ${selector}`, () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import octiconFile from '../../public/assets/img/svg/octicon-file.svg';
|
||||
import octiconFileCode from '../../public/assets/img/svg/octicon-file-code.svg';
|
||||
import octiconFileDirectoryFill from '../../public/assets/img/svg/octicon-file-directory-fill.svg';
|
||||
import octiconFileDirectoryOpenFill from '../../public/assets/img/svg/octicon-file-directory-open-fill.svg';
|
||||
import octiconFileRemoved from '../../public/assets/img/svg/octicon-file-removed.svg';
|
||||
import octiconFileSubmodule from '../../public/assets/img/svg/octicon-file-submodule.svg';
|
||||
import octiconFileSymlinkFile from '../../public/assets/img/svg/octicon-file-symlink-file.svg';
|
||||
import octiconFilter from '../../public/assets/img/svg/octicon-filter.svg';
|
||||
@@ -44,7 +45,9 @@ import octiconGitPullRequestClosed from '../../public/assets/img/svg/octicon-git
|
||||
import octiconGitPullRequestDraft from '../../public/assets/img/svg/octicon-git-pull-request-draft.svg';
|
||||
import octiconGrabber from '../../public/assets/img/svg/octicon-grabber.svg';
|
||||
import octiconHeading from '../../public/assets/img/svg/octicon-heading.svg';
|
||||
import octiconHistory from '../../public/assets/img/svg/octicon-history.svg';
|
||||
import octiconHorizontalRule from '../../public/assets/img/svg/octicon-horizontal-rule.svg';
|
||||
import octiconHome from '../../public/assets/img/svg/octicon-home.svg';
|
||||
import octiconImage from '../../public/assets/img/svg/octicon-image.svg';
|
||||
import octiconIssueClosed from '../../public/assets/img/svg/octicon-issue-closed.svg';
|
||||
import octiconIssueOpened from '../../public/assets/img/svg/octicon-issue-opened.svg';
|
||||
@@ -116,6 +119,7 @@ const svgs = {
|
||||
'octicon-file-code': octiconFileCode,
|
||||
'octicon-file-directory-fill': octiconFileDirectoryFill,
|
||||
'octicon-file-directory-open-fill': octiconFileDirectoryOpenFill,
|
||||
'octicon-file-removed': octiconFileRemoved,
|
||||
'octicon-file-submodule': octiconFileSubmodule,
|
||||
'octicon-file-symlink-file': octiconFileSymlinkFile,
|
||||
'octicon-filter': octiconFilter,
|
||||
@@ -128,7 +132,9 @@ const svgs = {
|
||||
'octicon-git-pull-request-draft': octiconGitPullRequestDraft,
|
||||
'octicon-grabber': octiconGrabber,
|
||||
'octicon-heading': octiconHeading,
|
||||
'octicon-history': octiconHistory,
|
||||
'octicon-horizontal-rule': octiconHorizontalRule,
|
||||
'octicon-home': octiconHome,
|
||||
'octicon-image': octiconImage,
|
||||
'octicon-issue-closed': octiconIssueClosed,
|
||||
'octicon-issue-opened': octiconIssueOpened,
|
||||
|
||||
@@ -42,12 +42,14 @@ export type Issue = {
|
||||
body: string,
|
||||
state: 'open' | 'closed',
|
||||
created_at: string,
|
||||
html_url: string,
|
||||
pull_request?: {
|
||||
draft: boolean;
|
||||
merged: boolean;
|
||||
},
|
||||
repository: {
|
||||
full_name: string,
|
||||
html_url: string,
|
||||
},
|
||||
labels: Array<string>,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
dirname, basename, extname, formatBytes, isObject, stripTags, parseIssueHref,
|
||||
translateMonth, translateDay, blobToDataURI,
|
||||
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo,
|
||||
urlQueryEscape,
|
||||
} from './utils.ts';
|
||||
|
||||
test('dirname', () => {
|
||||
@@ -34,12 +33,6 @@ test('stripTags', () => {
|
||||
expect(stripTags('<a>test</a>')).toEqual('test');
|
||||
});
|
||||
|
||||
test('urlQueryEscape', () => {
|
||||
const input = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
|
||||
const expected = '%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~';
|
||||
expect(urlQueryEscape(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('parseIssueHref', () => {
|
||||
expect(parseIssueHref('/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||
expect(parseIssueHref('/owner/repo/pulls/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
|
||||
|
||||
@@ -51,15 +51,6 @@ export function stripTags(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
export function urlQueryEscape(s: string) {
|
||||
// See "TestQueryEscape" in backend
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
|
||||
return encodeURIComponent(s).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseIssueHref(href: string): IssuePathInfo {
|
||||
// FIXME: it should use pathname and trim the appSubUrl ahead
|
||||
const path = (href || '').replace(/[#?].*$/, '');
|
||||
|
||||
@@ -2,6 +2,7 @@ import emojis from '../../../assets/emoji.json' with {type: 'json'};
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {parseIssuePageInfo} from '../utils.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import type {Issue, Mention} from '../types.ts';
|
||||
|
||||
const maxMatches = 6;
|
||||
@@ -47,7 +48,7 @@ export function fetchMentions(mentionsUrl: string): Promise<Mention[]> {
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return await res.json() as Mention[];
|
||||
} catch (e) {
|
||||
showErrorToast(`Failed to load mentions: ${e}`);
|
||||
showErrorToast(`Failed to load mentions: ${errorMessage(e)}`);
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import {linkifyURLs, pathEscapeSegments, toOriginUrl} from './url.ts';
|
||||
import {linkifyURLs, pathEscape, pathEscapeSegments, toOriginUrl, urlQueryEscape} from './url.ts';
|
||||
|
||||
test('pathEscapeSegments', () => {
|
||||
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
|
||||
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
|
||||
describe('escape', () => {
|
||||
const queryNonAscii = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
|
||||
test('urlQueryEscape', () => {
|
||||
const expected = '+%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~';
|
||||
expect(urlQueryEscape(queryNonAscii)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('pathEscape', () => {
|
||||
const expected = '%20%21%22%23$%25&%27%28%29%2A+%2C-.%2F:%3B%3C=%3E%3F@%5B%5C%5D%5E_%60%7B%7C%7D~';
|
||||
expect(pathEscape(queryNonAscii)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('pathEscapeSegments', () => {
|
||||
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
|
||||
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
|
||||
expect(pathEscapeSegments('a/b+c')).toEqual('a/b+c');
|
||||
});
|
||||
});
|
||||
|
||||
test('linkifyURLs', () => {
|
||||
|
||||
+29
-1
@@ -1,5 +1,33 @@
|
||||
export function urlQueryEscape(s: string) {
|
||||
// See "TestQueryEscape" in backend
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986
|
||||
return encodeURIComponent(s).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
).replaceAll('%20', '+');
|
||||
}
|
||||
|
||||
export function pathEscape(s: string): string {
|
||||
// See "TestPathEscape" in backend
|
||||
return encodeURIComponent(s).replace(
|
||||
/[!'()*]/g,
|
||||
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
).replaceAll(/%(\w\w)/g, (v) => {
|
||||
switch (v) {
|
||||
case '%24': return '$';
|
||||
case '%26': return '&';
|
||||
case '%2B': return '+';
|
||||
case '%3A': return ':';
|
||||
case '%3D': return '=';
|
||||
case '%40': return '@';
|
||||
default: return v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function pathEscapeSegments(s: string): string {
|
||||
return s.split('/').map(encodeURIComponent).join('/');
|
||||
// The same as backend's PathEscapeSegments
|
||||
return s.split('/').map(pathEscape).join('/');
|
||||
}
|
||||
|
||||
// Match HTML tags (to skip) or URLs (to linkify) in HTML content
|
||||
|
||||
@@ -3,13 +3,13 @@ import {addDelegatedEventListener, generateElemId, isDocumentFragmentOrElementNo
|
||||
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
|
||||
|
||||
window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||
popup: HTMLDivElement;
|
||||
overflowItems: Array<HTMLElement>;
|
||||
button: HTMLButtonElement | null;
|
||||
menuItemsEl: HTMLElement;
|
||||
resizeObserver: ResizeObserver;
|
||||
mutationObserver: MutationObserver;
|
||||
lastWidth: number;
|
||||
popup!: HTMLDivElement;
|
||||
overflowItems: Array<HTMLElement> = [];
|
||||
button: HTMLButtonElement | null = null;
|
||||
menuItemsEl!: HTMLElement;
|
||||
resizeObserver!: ResizeObserver;
|
||||
mutationObserver!: MutationObserver;
|
||||
lastWidth!: number;
|
||||
|
||||
updateButtonActivationState() {
|
||||
if (!this.button || !this.popup) return;
|
||||
@@ -100,7 +100,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||
const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button');
|
||||
|
||||
// move items in popup back into the menu items for subsequent measurement
|
||||
for (const item of this.overflowItems || []) {
|
||||
for (const item of this.overflowItems) {
|
||||
if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) {
|
||||
this.menuItemsEl.append(item);
|
||||
} else {
|
||||
|
||||
@@ -54,6 +54,26 @@ test('switches to datetime format after default threshold', async () => {
|
||||
expect(getText(el)).toMatch(/on [A-Z][a-z]{2} \d{1,2}/);
|
||||
});
|
||||
|
||||
test('accepts unix seconds as integer string', async () => {
|
||||
const el = createRelativeTime(String(Math.floor(Date.now() / 1000) - 3 * 60));
|
||||
await Promise.resolve();
|
||||
expect(getText(el)).toBe('3 minutes ago');
|
||||
});
|
||||
|
||||
test('ignores fractional unix seconds', async () => {
|
||||
const el = createRelativeTime('1700000000.5');
|
||||
el.shadowRoot!.textContent = 'fallback';
|
||||
await Promise.resolve();
|
||||
expect(getText(el)).toBe('fallback');
|
||||
});
|
||||
|
||||
test('ignores negative unix seconds', async () => {
|
||||
const el = createRelativeTime('-86400');
|
||||
el.shadowRoot!.textContent = 'fallback';
|
||||
await Promise.resolve();
|
||||
expect(getText(el)).toBe('fallback');
|
||||
});
|
||||
|
||||
test('ignores invalid datetime', async () => {
|
||||
const el = createRelativeTime('bogus');
|
||||
el.shadowRoot!.textContent = 'fallback';
|
||||
@@ -61,6 +81,13 @@ test('ignores invalid datetime', async () => {
|
||||
expect(getText(el)).toBe('fallback');
|
||||
});
|
||||
|
||||
test('ignores partial numeric datetime', async () => {
|
||||
const el = createRelativeTime('123abc');
|
||||
el.shadowRoot!.textContent = 'fallback';
|
||||
await Promise.resolve();
|
||||
expect(getText(el)).toBe('fallback');
|
||||
});
|
||||
|
||||
test('handles empty datetime', async () => {
|
||||
const el = createRelativeTime('');
|
||||
el.shadowRoot!.textContent = 'fallback';
|
||||
@@ -109,6 +136,18 @@ test('respects lang from parent element', async () => {
|
||||
expect(getText(el)).toBe('vor 3 Tagen');
|
||||
});
|
||||
|
||||
test('falls back when navigator.language is invalid', async () => {
|
||||
vi.spyOn(navigator, 'language', 'get').mockReturnValue('undefined');
|
||||
try {
|
||||
const el = document.createElement('relative-time');
|
||||
el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString());
|
||||
await Promise.resolve();
|
||||
expect(getText(el)).toBe('3 minutes ago');
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test('switches to datetime with P1D threshold', async () => {
|
||||
const el = createRelativeTime(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), {
|
||||
lang: 'en-US',
|
||||
|
||||
@@ -28,6 +28,7 @@ type FormatStyle = 'long' | 'short' | 'narrow';
|
||||
const unitNames = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'] as const;
|
||||
|
||||
const durationRe = /^[-+]?P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
||||
const unixSecondsRe = /^\d+$/;
|
||||
|
||||
function parseDurationMs(str: string): number {
|
||||
const m = durationRe.exec(str);
|
||||
@@ -258,13 +259,13 @@ class RelativeTime extends HTMLElement {
|
||||
}
|
||||
|
||||
get #lang(): string {
|
||||
const lang = this.closest('[lang]')?.getAttribute('lang');
|
||||
if (lang) {
|
||||
for (const candidate of [this.closest('[lang]')?.getAttribute('lang'), navigator.language]) {
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
return new Intl.Locale(lang).toString();
|
||||
} catch { /* invalid locale, fall through */ }
|
||||
return String(new Intl.Locale(candidate));
|
||||
} catch {}
|
||||
}
|
||||
return navigator.language ?? 'en';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
get second(): 'numeric' | '2-digit' | undefined {
|
||||
@@ -364,7 +365,8 @@ class RelativeTime extends HTMLElement {
|
||||
}
|
||||
|
||||
get date(): Date | null {
|
||||
const parsed = Date.parse(this.datetime);
|
||||
const dt = this.datetime;
|
||||
const parsed = unixSecondsRe.test(dt) ? Number(dt) * 1000 : Date.parse(dt);
|
||||
return Number.isNaN(parsed) ? null : new Date(parsed);
|
||||
}
|
||||
|
||||
@@ -432,7 +434,7 @@ class RelativeTime extends HTMLElement {
|
||||
const value = d[`${unit}s` as keyof Duration] as number;
|
||||
if (value || (duration.blank && unit === 'second')) {
|
||||
try {
|
||||
parts.push(new Intl.NumberFormat(locale, {style: 'unit', unit, unitDisplay: style} as Intl.NumberFormatOptions).format(value));
|
||||
parts.push(new Intl.NumberFormat(locale, {style: 'unit', unit, unitDisplay: style}).format(value));
|
||||
} catch { // PaleMoon lacks Intl.NumberFormat unit style support
|
||||
parts.push(`${value} ${value === 1 ? unit : `${unit}s`}`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user