diff --git a/routers/web/projects/workflows.go b/routers/web/projects/workflows.go index 11ea90baac..1897b8434a 100644 --- a/routers/web/projects/workflows.go +++ b/routers/web/projects/workflows.go @@ -290,16 +290,22 @@ func WorkflowsLabels(ctx *context.Context) { } type Label struct { - ID int64 `json:"id"` - Name string `json:"name"` - Color string `json:"color"` + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` + Exclusive bool `json:"exclusive"` + ExclusiveOrder int `json:"exclusiveOrder"` } outputLabels := make([]*Label, 0, len(labels)) for _, label := range labels { outputLabels = append(outputLabels, &Label{ - ID: label.ID, - Name: label.Name, - Color: label.Color, + ID: label.ID, + Name: label.Name, + Color: label.Color, + Description: label.Description, + Exclusive: label.Exclusive, + ExclusiveOrder: label.ExclusiveOrder, }) } diff --git a/web_src/css/modules/label.css b/web_src/css/modules/label.css index cf850e4c5a..f4ac145b2d 100644 --- a/web_src/css/modules/label.css +++ b/web_src/css/modules/label.css @@ -334,6 +334,26 @@ If the labels-list itself needs some layouts, use extra classes or "tw" helpers. border-top-left-radius: 0; } +/* Exclusive label priority order - the numeric display (div.scope-right without ui.label class) */ +.ui.label.scope-parent > .scope-right:not(.ui) { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.08); + color: var(--color-text); + font-weight: 600; + min-width: 2em; + padding: 0.5833em 0.833em; + border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-bottom-right-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); +} + +[data-theme="dark"] .ui.label.scope-parent > .scope-right:not(.ui) { + background-color: rgba(255, 255, 255, 0.15); +} + .ui.label.archived-label { filter: grayscale(0.5); opacity: 0.5; diff --git a/web_src/js/components/LabelSelector.vue b/web_src/js/components/LabelSelector.vue index 5a86453239..33f60b1a30 100644 --- a/web_src/js/components/LabelSelector.vue +++ b/web_src/js/components/LabelSelector.vue @@ -8,6 +8,8 @@ interface Label { name: string; color: string; description?: string; + exclusive?: boolean; + exclusiveOrder?: number; } const props = withDefaults(defineProps<{ @@ -40,16 +42,109 @@ const getLabelTextColor = (hexColor: string) => { return contrastColor(hexColor); }; +// Convert hex color to RGB +const hexToRGB = (hex: string): {r: number; g: number; b: number} => { + const color = hex.replace(/^#/, ''); + return { + r: Number.parseInt(color.substring(0, 2), 16), + g: Number.parseInt(color.substring(2, 4), 16), + b: Number.parseInt(color.substring(4, 6), 16), + }; +}; + +// Get relative luminance of a color +const getRelativeLuminance = (hex: string): number => { + const {r, g, b} = hexToRGB(hex); + const rsRGB = r / 255; + const gsRGB = g / 255; + const bsRGB = b / 255; + + const rLinear = rsRGB <= 0.03928 ? rsRGB / 12.92 : Math.pow((rsRGB + 0.055) / 1.055, 2.4); + const gLinear = gsRGB <= 0.03928 ? gsRGB / 12.92 : Math.pow((gsRGB + 0.055) / 1.055, 2.4); + const bLinear = bsRGB <= 0.03928 ? bsRGB / 12.92 : Math.pow((bsRGB + 0.055) / 1.055, 2.4); + + return 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear; +}; + +// Get scope and item colors for exclusive labels +const getScopeColors = (baseColor: string): {scopeColor: string; itemColor: string} => { + const luminance = getRelativeLuminance(baseColor); + const contrast = 0.01 + luminance * 0.03; + const darken = contrast + Math.max(luminance + contrast - 1.0, 0.0); + const lighten = contrast + Math.max(contrast - luminance, 0.0); + const darkenFactor = Math.max(luminance - darken, 0.0) / Math.max(luminance, 1.0 / 255.0); + const lightenFactor = Math.min(luminance + lighten, 1.0) / Math.max(luminance, 1.0 / 255.0); + + const {r, g, b} = hexToRGB(baseColor); + + const scopeR = Math.min(Math.round(r * darkenFactor), 255); + const scopeG = Math.min(Math.round(g * darkenFactor), 255); + const scopeB = Math.min(Math.round(b * darkenFactor), 255); + + const itemR = Math.min(Math.round(r * lightenFactor), 255); + const itemG = Math.min(Math.round(g * lightenFactor), 255); + const itemB = Math.min(Math.round(b * lightenFactor), 255); + + const scopeColor = `#${scopeR.toString(16).padStart(2, '0')}${scopeG.toString(16).padStart(2, '0')}${scopeB.toString(16).padStart(2, '0')}`; + const itemColor = `#${itemR.toString(16).padStart(2, '0')}${itemG.toString(16).padStart(2, '0')}${itemB.toString(16).padStart(2, '0')}`; + + return {scopeColor, itemColor}; +}; + +// Get exclusive scope from label name +const getExclusiveScope = (label: Label): string => { + if (!label.exclusive) return ''; + const lastIndex = label.name.lastIndexOf('/'); + if (lastIndex === -1 || lastIndex === 0 || lastIndex === label.name.length - 1) { + return ''; + } + return label.name.substring(0, lastIndex); +}; + +// Get label scope part (before the '/') +const getLabelScope = (label: Label): string => { + const scope = getExclusiveScope(label); + return scope || ''; +}; + +// Get label item part (after the '/') +const getLabelItem = (label: Label): string => { + const scope = getExclusiveScope(label); + if (!scope) return label.name; + return label.name.substring(scope.length + 1); +}; + // Toggle label selection const toggleLabel = (labelId: string) => { if (props.readonly) return; + const clickedLabel = props.labels.find((l) => String(l.id) === labelId); + if (!clickedLabel) return; + const currentValues = [...props.modelValue]; const index = currentValues.indexOf(labelId); if (index > -1) { + // Remove the label if already selected currentValues.splice(index, 1); } else { + // Handle exclusive labels: remove other labels in same scope + const exclusiveScope = getExclusiveScope(clickedLabel); + if (exclusiveScope) { + // Remove all labels with the same exclusive scope + const labelsToRemove = props.labels + .filter((l) => { + const scope = getExclusiveScope(l); + return scope === exclusiveScope && String(l.id) !== labelId; + }) + .map((l) => String(l.id)); + + labelsToRemove.forEach((id) => { + const idx = currentValues.indexOf(id); + if (idx > -1) currentValues.splice(idx, 1); + }); + } + if (props.multiple) { currentValues.push(labelId); } else { @@ -98,14 +193,45 @@ onMounted(async () => {