feat(frontend): add thumbnail support to TagsSelect component
Display item and pokemon images in dropdown options and selected values Update Admin, Dish, Habitat, Pokemon, and Recipe views to pass image URLs
This commit is contained in:
@@ -8,12 +8,14 @@ export type TagsSelectOption = {
|
|||||||
id: number | string;
|
id: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OptionRow = {
|
type OptionRow = {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string };
|
type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string };
|
||||||
@@ -65,7 +67,8 @@ const optionRows = computed(() =>
|
|||||||
props.options.map((option, index) => ({
|
props.options.map((option, index) => ({
|
||||||
value: String(option.id),
|
value: String(option.id),
|
||||||
label: option.label ?? option.name,
|
label: option.label ?? option.name,
|
||||||
id: `${props.id}-option-${index}`
|
id: `${props.id}-option-${index}`,
|
||||||
|
thumbnailUrl: option.thumbnailUrl ?? null
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -79,9 +82,10 @@ const maxReached = computed(() => props.multiple && props.max > 0 && modelValues
|
|||||||
const selectedRows = computed(() =>
|
const selectedRows = computed(() =>
|
||||||
modelValues.value
|
modelValues.value
|
||||||
.map((value) => optionRows.value.find((option) => option.value === value))
|
.map((value) => optionRows.value.find((option) => option.value === value))
|
||||||
.filter((option) => option !== undefined)
|
.filter((option): option is OptionRow => option !== undefined)
|
||||||
);
|
);
|
||||||
const selectedLabel = computed(() => selectedRows.value[0]?.label ?? '');
|
const selectedLabel = computed(() => selectedRows.value[0]?.label ?? '');
|
||||||
|
const selectedThumbnailUrl = computed(() => selectedRows.value[0]?.thumbnailUrl ?? '');
|
||||||
|
|
||||||
const filteredRows = computed(() => {
|
const filteredRows = computed(() => {
|
||||||
const keyword = search.value.trim().toLowerCase();
|
const keyword = search.value.trim().toLowerCase();
|
||||||
@@ -360,6 +364,7 @@ watch(
|
|||||||
<span v-if="selectedRows.length" class="tags-select__selected">
|
<span v-if="selectedRows.length" class="tags-select__selected">
|
||||||
<template v-if="multiple">
|
<template v-if="multiple">
|
||||||
<span v-for="option in selectedRows" :key="option.value" class="tags-select__tag">
|
<span v-for="option in selectedRows" :key="option.value" class="tags-select__tag">
|
||||||
|
<img v-if="option.thumbnailUrl" class="tags-select__thumb tags-select__thumb--tag" :src="option.thumbnailUrl" alt="" loading="lazy" />
|
||||||
<span>{{ option.label }}</span>
|
<span>{{ option.label }}</span>
|
||||||
<span
|
<span
|
||||||
class="tags-select__remove"
|
class="tags-select__remove"
|
||||||
@@ -374,7 +379,10 @@ watch(
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="tags-select__single-value">{{ selectedLabel }}</span>
|
<span v-else class="tags-select__single-value">
|
||||||
|
<img v-if="selectedThumbnailUrl" class="tags-select__thumb" :src="selectedThumbnailUrl" alt="" loading="lazy" />
|
||||||
|
<span>{{ selectedLabel }}</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="tags-select__placeholder">{{ placeholderText }}</span>
|
<span v-else class="tags-select__placeholder">{{ placeholderText }}</span>
|
||||||
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
|
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
|
||||||
@@ -417,7 +425,10 @@ watch(
|
|||||||
:disabled="!selectedValues.has(option.value) && maxReached"
|
:disabled="!selectedValues.has(option.value) && maxReached"
|
||||||
@click="selectOption(option.value)"
|
@click="selectOption(option.value)"
|
||||||
>
|
>
|
||||||
<span>{{ option.label }}</span>
|
<span class="tags-select__option-label">
|
||||||
|
<img v-if="option.thumbnailUrl" class="tags-select__thumb" :src="option.thumbnailUrl" alt="" loading="lazy" />
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</span>
|
||||||
<span v-if="selectedValues.has(option.value)" class="tags-select__state">
|
<span v-if="selectedValues.has(option.value)" class="tags-select__state">
|
||||||
<Icon :icon="iconCheck" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconCheck" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('common.selected') }}
|
{{ t('common.selected') }}
|
||||||
|
|||||||
@@ -2041,10 +2041,18 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tags-select__single-value {
|
.tags-select__single-value {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-select__single-value span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -2054,6 +2062,7 @@ button:disabled,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border: 1px solid rgba(42, 117, 187, 0.28);
|
border: 1px solid rgba(42, 117, 187, 0.28);
|
||||||
@@ -2064,6 +2073,27 @@ button:disabled,
|
|||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tags-select__tag > span:first-of-type {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-select__thumb {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-select__thumb--tag {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.tags-select__remove {
|
.tags-select__remove {
|
||||||
min-width: 18px;
|
min-width: 18px;
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
@@ -2145,6 +2175,20 @@ button:disabled,
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tags-select__option-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-select__option-label span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.tags-select__option:hover,
|
.tags-select__option:hover,
|
||||||
.tags-select__option.active,
|
.tags-select__option.active,
|
||||||
.tags-select__option.selected {
|
.tags-select__option.selected {
|
||||||
|
|||||||
@@ -371,7 +371,9 @@ const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDis
|
|||||||
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
|
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
|
||||||
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
|
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
|
||||||
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
|
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
|
||||||
const dishItemSelectOptions = computed<TagsSelectOption[]>(() => dishItemRows.value.map((item) => ({ id: item.id, name: item.name })));
|
const dishItemSelectOptions = computed<TagsSelectOption[]>(() =>
|
||||||
|
dishItemRows.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }))
|
||||||
|
);
|
||||||
const optionalDishItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...dishItemSelectOptions.value]);
|
const optionalDishItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...dishItemSelectOptions.value]);
|
||||||
const dishCategorySelectOptions = computed<TagsSelectOption[]>(() =>
|
const dishCategorySelectOptions = computed<TagsSelectOption[]>(() =>
|
||||||
dishCategoryRows.value.map((category) => ({ id: category.id, name: category.name }))
|
dishCategoryRows.value.map((category) => ({ id: category.id, name: category.name }))
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const dishCategoryModalTitle = computed(() =>
|
|||||||
);
|
);
|
||||||
const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDish') : t('pages.dish.newDish')));
|
const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDish') : t('pages.dish.newDish')));
|
||||||
const itemSelectOptions = computed<TagsSelectOption[]>(() =>
|
const itemSelectOptions = computed<TagsSelectOption[]>(() =>
|
||||||
items.value.map((item) => ({ id: item.id, name: item.name }))
|
items.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }))
|
||||||
);
|
);
|
||||||
const optionalItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...itemSelectOptions.value]);
|
const optionalItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...itemSelectOptions.value]);
|
||||||
const categorySelectOptions = computed<TagsSelectOption[]>(() => categories.value.map((category) => ({ id: category.id, name: category.name })));
|
const categorySelectOptions = computed<TagsSelectOption[]>(() => categories.value.map((category) => ({ id: category.id, name: category.name })));
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ const weatherOptions = computed(() => [
|
|||||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||||
const isEditing = computed(() => routeId.value !== '');
|
const isEditing = computed(() => routeId.value !== '');
|
||||||
const isEventCreate = computed(() => route.name === 'event-habitat-new');
|
const isEventCreate = computed(() => route.name === 'event-habitat-new');
|
||||||
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
|
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url })));
|
||||||
const pokemonSelectOptions = computed(() =>
|
const pokemonSelectOptions = computed(() =>
|
||||||
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.displayId} ${pokemon.name}` }))
|
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.displayId} ${pokemon.name}`, thumbnailUrl: pokemon.image?.url }))
|
||||||
);
|
);
|
||||||
const pageTitle = computed(() =>
|
const pageTitle = computed(() =>
|
||||||
isEditing.value
|
isEditing.value
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import PokemonStatsFields from '../components/PokemonStatsFields.vue';
|
|||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import Tabs from '../components/Tabs.vue';
|
import Tabs from '../components/Tabs.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
|
||||||
import TranslationFields from '../components/TranslationFields.vue';
|
import TranslationFields from '../components/TranslationFields.vue';
|
||||||
import { iconCancel, iconSave, iconSearch } from '../icons';
|
import { iconCancel, iconSave, iconSearch } from '../icons';
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
type EntityImage,
|
type EntityImage,
|
||||||
type EntityImageUpload,
|
type EntityImageUpload,
|
||||||
type Language,
|
type Language,
|
||||||
type NamedEntity,
|
|
||||||
type Options,
|
type Options,
|
||||||
type PokemonFetchOption,
|
type PokemonFetchOption,
|
||||||
type PokemonFetchResult,
|
type PokemonFetchResult,
|
||||||
@@ -39,7 +38,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
const itemOptions = ref<NamedEntity[]>([]);
|
const itemOptions = ref<TagsSelectOption[]>([]);
|
||||||
const languages = ref<Language[]>([]);
|
const languages = ref<Language[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -189,7 +188,7 @@ function errorText(error: unknown, fallback: string) {
|
|||||||
async function loadOptions() {
|
async function loadOptions() {
|
||||||
const [loadedOptions, loadedItems, loadedLanguages] = await Promise.all([api.options(), api.items({}), api.languages()]);
|
const [loadedOptions, loadedItems, loadedLanguages] = await Promise.all([api.options(), api.items({}), api.languages()]);
|
||||||
options.value = loadedOptions;
|
options.value = loadedOptions;
|
||||||
itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name }));
|
itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }));
|
||||||
languages.value = loadedLanguages;
|
languages.value = loadedLanguages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ const recipeForm = ref({
|
|||||||
|
|
||||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||||
const isEditing = computed(() => routeId.value !== '');
|
const isEditing = computed(() => routeId.value !== '');
|
||||||
const materialItemOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
|
const materialItemOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url })));
|
||||||
const resultItemOptions = computed(() =>
|
const resultItemOptions = computed(() =>
|
||||||
itemRows.value
|
itemRows.value
|
||||||
.filter((item) => !item.noRecipe || String(item.id) === recipeForm.value.itemId)
|
.filter((item) => !item.noRecipe || String(item.id) === recipeForm.value.itemId)
|
||||||
.map((item) => ({ id: item.id, name: item.name }))
|
.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }))
|
||||||
);
|
);
|
||||||
const selectedItemName = computed(() => resultItemOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? '');
|
const selectedItemName = computed(() => resultItemOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? '');
|
||||||
const pageTitle = computed(() =>
|
const pageTitle = computed(() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user