mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 21:33:02 +01:00
Custom Colored Labels (#801)
* feat: custom coloured labels * chore: lint * feat: add ColorSelector component for improved color selection in labels and integrate it into CreateModal and Selector components * style: lint * fix: update ColorSelector and Selector components to use empty string instead of null for default color values for types
This commit is contained in:
@@ -3578,6 +3578,9 @@ const docTemplate = `{
|
||||
"repo.LabelOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"color": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3598,6 +3601,9 @@ const docTemplate = `{
|
||||
"repo.LabelSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"color": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -3576,6 +3576,9 @@
|
||||
"repo.LabelOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"color": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3596,6 +3599,9 @@
|
||||
"repo.LabelSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"color": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -997,6 +997,8 @@ definitions:
|
||||
type: object
|
||||
repo.LabelOut:
|
||||
properties:
|
||||
color:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
description:
|
||||
@@ -1010,6 +1012,8 @@ definitions:
|
||||
type: object
|
||||
repo.LabelSummary:
|
||||
properties:
|
||||
color:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
description:
|
||||
|
||||
@@ -35,6 +35,7 @@ type (
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -49,6 +50,7 @@ func mapLabelSummary(label *ent.Label) LabelSummary {
|
||||
ID: label.ID,
|
||||
Name: label.Name,
|
||||
Description: label.Description,
|
||||
Color: label.Color,
|
||||
CreatedAt: label.CreatedAt,
|
||||
UpdatedAt: label.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -3576,6 +3576,9 @@
|
||||
"repo.LabelOut": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"color": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3596,6 +3599,9 @@
|
||||
"repo.LabelSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"color": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -997,6 +997,8 @@ definitions:
|
||||
type: object
|
||||
repo.LabelOut:
|
||||
properties:
|
||||
color:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
description:
|
||||
@@ -1010,6 +1012,8 @@ definitions:
|
||||
type: object
|
||||
repo.LabelSummary:
|
||||
properties:
|
||||
color:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
description:
|
||||
|
||||
155
frontend/components/Form/ColorSelector.vue
Normal file
155
frontend/components/Form/ColorSelector.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
import MdiDiceMultiple from "~icons/mdi/dice-multiple";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: "",
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showHex: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
size: {
|
||||
type: [String, Number],
|
||||
default: 24,
|
||||
},
|
||||
startingColor: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(["update:modelValue"]);
|
||||
|
||||
const id = useId();
|
||||
|
||||
const swatchStyle = computed(() => ({
|
||||
backgroundColor: props.modelValue || "hsl(var(--muted))",
|
||||
width: typeof props.size === "number" ? `${props.size}px` : props.size,
|
||||
height: typeof props.size === "number" ? `${props.size}px` : props.size,
|
||||
}));
|
||||
|
||||
const value = useVModel(props, "modelValue", emits);
|
||||
|
||||
// Initialize with starting color if provided and current value is empty
|
||||
onMounted(() => {
|
||||
if (props.startingColor && (!value.value || value.value === "")) {
|
||||
value.value = props.startingColor;
|
||||
}
|
||||
});
|
||||
|
||||
function clearColor() {
|
||||
value.value = "";
|
||||
}
|
||||
|
||||
function randomizeColor() {
|
||||
const randomColor =
|
||||
"#" +
|
||||
Math.floor(Math.random() * 16777215)
|
||||
.toString(16)
|
||||
.padStart(6, "0");
|
||||
value.value = randomColor;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
|
||||
<Label :for="id" class="flex w-full px-1">
|
||||
<span>{{ label }}</span>
|
||||
</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:style="swatchStyle"
|
||||
class="inline-block cursor-pointer rounded-full border ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
:aria-label="`${t('components.color_selector.color')}: ${modelValue || t('components.color_selector.no_color_selected')}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="$refs.colorInput.click()"
|
||||
/>
|
||||
<span v-if="showHex" class="font-mono text-xs text-muted-foreground">{{
|
||||
modelValue || t("components.color_selector.no_color")
|
||||
}}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="size-6 p-0"
|
||||
:aria-label="t('components.color_selector.randomize')"
|
||||
@click="randomizeColor"
|
||||
>
|
||||
<MdiDiceMultiple class="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="size-6 p-0"
|
||||
:aria-label="t('components.color_selector.clear')"
|
||||
@click="clearColor"
|
||||
>
|
||||
<MdiClose class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<input :id="id" ref="colorInput" v-model="value" type="color" class="sr-only" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||
<Label class="flex w-full px-1 py-2" :for="id">
|
||||
<span>{{ label }}</span>
|
||||
</Label>
|
||||
<div class="col-span-3 mt-2 flex items-center gap-2">
|
||||
<span
|
||||
:style="swatchStyle"
|
||||
class="inline-block cursor-pointer rounded-full border ring-offset-background focus:outline-none focus:outline-primary focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
:aria-label="`${t('components.color_selector.color')}: ${modelValue || t('components.color_selector.no_color_selected')}`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="$refs.colorInput.click()"
|
||||
/>
|
||||
<span v-if="showHex" class="font-mono text-xs text-muted-foreground">{{
|
||||
modelValue || t("components.color_selector.no_color")
|
||||
}}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="size-6 p-0"
|
||||
:aria-label="t('components.color_selector.randomize')"
|
||||
@click="randomizeColor"
|
||||
>
|
||||
<MdiDiceMultiple class="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="size-6 p-0"
|
||||
:aria-label="t('components.color_selector.clear')"
|
||||
@click="clearColor"
|
||||
>
|
||||
<MdiClose class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<input :id="id" ref="colorInput" v-model="value" type="color" class="sr-only" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { LabelOut, LabelSummary } from "~~/lib/api/types/data-contracts";
|
||||
import MdiArrowUp from "~icons/mdi/arrow-up";
|
||||
import MdiTagOutline from "~icons/mdi/tag-outline";
|
||||
import { getContrastTextColor } from "~/lib/utils";
|
||||
|
||||
export type sizes = "sm" | "md" | "lg" | "xl";
|
||||
defineProps({
|
||||
@@ -18,12 +19,17 @@
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
class="group/label-chip flex gap-2 rounded-full bg-accent text-accent-foreground shadow transition duration-300 hover:bg-accent/50"
|
||||
class="group/label-chip flex gap-2 rounded-full shadow transition duration-300 hover:bg-accent/50"
|
||||
:class="{
|
||||
'p-4 py-1 text-base': size === 'lg',
|
||||
'p-3 py-1 text-sm': size !== 'sm' && size !== 'lg',
|
||||
'p-2 py-0.5 text-xs': size === 'sm',
|
||||
}"
|
||||
:style="
|
||||
label.color
|
||||
? { backgroundColor: label.color, color: getContrastTextColor(label.color) }
|
||||
: { backgroundColor: 'hsl(var(--accent))' }
|
||||
"
|
||||
:to="`/label/${label.id}`"
|
||||
>
|
||||
<div class="relative">
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
:label="$t('components.label.create_modal.label_description')"
|
||||
:max-length="255"
|
||||
/>
|
||||
<ColorSelector v-model="form.color" :label="$t('components.label.create_modal.label_color')" :show-hex="true" />
|
||||
<div class="mt-4 flex flex-row-reverse">
|
||||
<ButtonGroup>
|
||||
<Button :disabled="loading" type="submit">{{ $t("global.create") }}</Button>
|
||||
@@ -31,6 +32,7 @@
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
import ColorSelector from "@/components/Form/ColorSelector.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 px-3">
|
||||
<TagsInputItem v-for="item in modelValue" :key="item" :value="item">
|
||||
<span
|
||||
v-if="shortenedLabels.find(l => l.id === item)?.color"
|
||||
class="ml-2 inline-block size-4 rounded-full"
|
||||
:style="{ backgroundColor: shortenedLabels.find(l => l.id === item)?.color }"
|
||||
/>
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
@@ -55,6 +60,11 @@
|
||||
}
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="mr-2 inline-block size-4 rounded-full align-middle"
|
||||
:class="{ border: shortenedLabels.find(l => l.id === label.value)?.color }"
|
||||
:style="{ backgroundColor: shortenedLabels.find(l => l.id === label.value)?.color }"
|
||||
/>
|
||||
{{ label.label }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
@@ -127,14 +137,14 @@
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const createAndAdd = async (name: string) => {
|
||||
const createAndAdd = async (name: string, color = "") => {
|
||||
if (name.length > 50) {
|
||||
toast.error(t("components.label.create_modal.toast.label_name_too_long"));
|
||||
return;
|
||||
}
|
||||
const { error, data } = await api.labels.create({
|
||||
name,
|
||||
color: "", // Future!
|
||||
color,
|
||||
description: "",
|
||||
});
|
||||
|
||||
|
||||
@@ -638,6 +638,7 @@ export interface LabelCreate {
|
||||
}
|
||||
|
||||
export interface LabelOut {
|
||||
color: string;
|
||||
createdAt: Date | string;
|
||||
description: string;
|
||||
id: string;
|
||||
@@ -646,6 +647,7 @@ export interface LabelOut {
|
||||
}
|
||||
|
||||
export interface LabelSummary {
|
||||
color: string;
|
||||
createdAt: Date | string;
|
||||
description: string;
|
||||
id: string;
|
||||
|
||||
@@ -4,3 +4,35 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns either '#000' or '#fff' depending on which has better contrast with the given background color.
|
||||
* Accepts hex (#RRGGBB or #RGB) or rgb(a) strings.
|
||||
*/
|
||||
export function getContrastTextColor(bgColor: string): string {
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
if (bgColor.startsWith("#")) {
|
||||
let hex = bgColor.slice(1);
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split("")
|
||||
.map(x => x + x)
|
||||
.join("");
|
||||
}
|
||||
r = parseInt(hex.slice(0, 2), 16);
|
||||
g = parseInt(hex.slice(2, 4), 16);
|
||||
b = parseInt(hex.slice(4, 6), 16);
|
||||
} else if (bgColor.startsWith("rgb")) {
|
||||
const match = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (match) {
|
||||
r = parseInt(match[1]);
|
||||
g = parseInt(match[2]);
|
||||
b = parseInt(match[3]);
|
||||
}
|
||||
}
|
||||
// Calculate luminance
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? "#000" : "#fff";
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
"new_version_available_link": "Click here to view the release notes"
|
||||
}
|
||||
},
|
||||
"color_selector": {
|
||||
"color": "Color",
|
||||
"clear": "Clear color",
|
||||
"no_color": "No color",
|
||||
"no_color_selected": "No color selected",
|
||||
"randomize": "Randomize color"
|
||||
},
|
||||
"form": {
|
||||
"password": {
|
||||
"toggle_show": "Toggle Password Show"
|
||||
@@ -144,7 +151,8 @@
|
||||
"create_failed": "Couldn't create label",
|
||||
"create_success": "Label created",
|
||||
"label_name_too_long": "Label name must not be longer than 50 characters"
|
||||
}
|
||||
},
|
||||
"label_color": "Label Color"
|
||||
},
|
||||
"selector": {
|
||||
"select_labels": "Select Labels"
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import ColorSelector from "@/components/Form/ColorSelector.vue";
|
||||
import { getContrastTextColor } from "~/lib/utils";
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["auth"],
|
||||
@@ -128,7 +130,12 @@
|
||||
:label="$t('components.label.create_modal.label_description')"
|
||||
:max-length="255"
|
||||
/>
|
||||
<!-- TODO: color -->
|
||||
<ColorSelector
|
||||
v-model="updateData.color"
|
||||
:label="$t('components.label.create_modal.label_color')"
|
||||
:show-hex="true"
|
||||
:starting-color="label.color"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" :loading="updating"> {{ $t("global.update") }} </Button>
|
||||
</DialogFooter>
|
||||
@@ -144,7 +151,12 @@
|
||||
<header :class="{ 'mb-2': label.description }">
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<div
|
||||
class="mb-auto flex size-12 items-center justify-center rounded-full bg-secondary text-secondary-foreground"
|
||||
class="mb-auto flex size-12 items-center justify-center rounded-full"
|
||||
:style="
|
||||
label.color
|
||||
? { backgroundColor: label.color, color: getContrastTextColor(label.color) }
|
||||
: { backgroundColor: 'hsl(var(--secondary))', color: 'hsl(var(--secondary-foreground))' }
|
||||
"
|
||||
>
|
||||
<MdiPackageVariant class="size-7" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user