feat(wiki): add community image upload for wiki entities
Support uploading images for Pokemon, Items, and Habitats Track upload history in new entity_image_uploads table Update entity cards to display uploaded images and usage ribbons
This commit is contained in:
@@ -108,6 +108,10 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
function imageFileName(path: string): string {
|
||||
return path.split('/').at(-1) ?? t('media.image');
|
||||
}
|
||||
|
||||
async function loadHabitatDetail() {
|
||||
habitat.value = await api.habitatDetail(String(route.params.id));
|
||||
}
|
||||
@@ -200,6 +204,21 @@ watch(
|
||||
<Tabs id="habitat-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||
|
||||
<div v-if="detailTab === 'details'" class="habitat-detail-stack">
|
||||
<DetailSection v-if="habitat.image || habitat.imageHistory.length" :title="t('media.image')">
|
||||
<div class="entity-detail-image">
|
||||
<div v-if="habitat.image" class="entity-detail-image__frame">
|
||||
<img :src="habitat.image.url" :alt="t('media.imageAlt', { name: habitat.name })" />
|
||||
</div>
|
||||
<p v-else class="meta-line">{{ t('media.imageEmpty') }}</p>
|
||||
<div v-if="habitat.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
|
||||
<div v-for="image in habitat.imageHistory" :key="image.path" class="image-history-list__item">
|
||||
<img :src="image.url" :alt="t('media.imageAlt', { name: habitat.name })" loading="lazy" />
|
||||
<span>{{ imageFileName(image.path) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection :title="t('pages.habitats.recipeList')">
|
||||
<EntityChips :items="habitat.recipe" />
|
||||
</DetailSection>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import ImageUploadField from '../components/ImageUploadField.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
@@ -13,6 +14,8 @@ import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons
|
||||
import {
|
||||
api,
|
||||
type ConfigType,
|
||||
type EntityImage,
|
||||
type EntityImageUpload,
|
||||
type HabitatDetail,
|
||||
type HabitatPayload,
|
||||
type Item,
|
||||
@@ -37,6 +40,8 @@ const options = ref<Options | null>(null);
|
||||
const itemRows = ref<Item[]>([]);
|
||||
const pokemonRows = ref<Pokemon[]>([]);
|
||||
const languages = ref<Language[]>([]);
|
||||
const currentImage = ref<EntityImage | null>(null);
|
||||
const imageHistory = ref<EntityImageUpload[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
@@ -44,6 +49,7 @@ const creatingSelect = ref('');
|
||||
const habitatForm = ref({
|
||||
name: '',
|
||||
translations: {} as TranslationMap,
|
||||
imagePath: '',
|
||||
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
|
||||
pokemonAppearances: [] as HabitatAppearanceForm[]
|
||||
});
|
||||
@@ -73,6 +79,7 @@ const pageTitle = computed(() =>
|
||||
: t('pages.habitats.newTitle')
|
||||
);
|
||||
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
|
||||
const imageEntityName = computed(() => habitatNameForSave().trim());
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
@@ -159,9 +166,12 @@ async function loadEditor() {
|
||||
habitatForm.value = {
|
||||
name: habitat.baseName ?? habitat.name,
|
||||
translations: habitat.translations ?? {},
|
||||
imagePath: habitat.image?.path ?? '',
|
||||
recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
|
||||
pokemonAppearances: groupPokemonAppearances(habitat)
|
||||
};
|
||||
currentImage.value = habitat.image;
|
||||
imageHistory.value = habitat.imageHistory;
|
||||
}
|
||||
} catch (error) {
|
||||
message.value = errorText(error, t('errors.loadFailed'));
|
||||
@@ -202,6 +212,7 @@ async function saveHabitat() {
|
||||
const payload: HabitatPayload = {
|
||||
name: habitatNameForSave(),
|
||||
translations: habitatForm.value.translations,
|
||||
imagePath: habitatForm.value.imagePath,
|
||||
recipeItems: toQuantityRows(habitatForm.value.recipeItems),
|
||||
pokemonAppearances: habitatForm.value.pokemonAppearances
|
||||
.map((item) => ({
|
||||
@@ -222,6 +233,15 @@ async function saveHabitat() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageSelected(image: EntityImage) {
|
||||
currentImage.value = image;
|
||||
}
|
||||
|
||||
function handleImageUploaded(image: EntityImageUpload) {
|
||||
currentImage.value = image;
|
||||
imageHistory.value = [image, ...imageHistory.value.filter((item) => item.path !== image.path)];
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadEditor();
|
||||
});
|
||||
@@ -242,6 +262,20 @@ onMounted(() => {
|
||||
required
|
||||
/>
|
||||
|
||||
<ImageUploadField
|
||||
v-model="habitatForm.imagePath"
|
||||
entity-type="habitats"
|
||||
:entity-id="isEditing ? routeId : null"
|
||||
:entity-name="imageEntityName"
|
||||
:label="t('media.image')"
|
||||
:current-image="currentImage"
|
||||
:history="imageHistory"
|
||||
:disabled="busy"
|
||||
@selected="handleImageSelected"
|
||||
@uploaded="handleImageUploaded"
|
||||
@error="message = $event"
|
||||
/>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ t('pages.habitats.recipe') }}</label>
|
||||
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EditMeta from '../components/EditMeta.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
@@ -19,6 +17,10 @@ const loading = ref(true);
|
||||
const skeletonCardCount = 6;
|
||||
const showEditor = computed(() => route.name === 'habitat-new');
|
||||
|
||||
function habitatCardImage(item: Habitat) {
|
||||
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
habitats.value = await api.habitats();
|
||||
loading.value = false;
|
||||
@@ -37,27 +39,23 @@ onMounted(async () => {
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.habitats.loadingList')">
|
||||
<div v-if="loading" class="entity-grid pokemon-list-grid" aria-busy="true" :aria-label="t('pages.habitats.loadingList')">
|
||||
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
|
||||
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
||||
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||
<div class="entity-card__content">
|
||||
<Skeleton width="68%" height="24px" />
|
||||
<Skeleton width="66%" />
|
||||
<div class="skeleton-chip-row">
|
||||
<Skeleton v-for="chipIndex in 3" :key="`recipe-${chipIndex}`" width="70px" class="skeleton-chip" />
|
||||
</div>
|
||||
<div class="skeleton-chip-row">
|
||||
<Skeleton v-for="chipIndex in 2" :key="`pokemon-${chipIndex}`" width="82px" class="skeleton-chip" />
|
||||
</div>
|
||||
<Skeleton width="128px" height="24px" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="entity-grid">
|
||||
<EntityCard v-for="item in habitats" :key="item.id" :title="item.name" :to="`/habitats/${item.id}`" :icon="iconHabitat">
|
||||
<EditMeta :entity="item" />
|
||||
<EntityChips :items="item.recipe" />
|
||||
<EntityChips :items="item.pokemon ?? []" />
|
||||
</EntityCard>
|
||||
<div v-else class="entity-grid pokemon-list-grid">
|
||||
<EntityCard
|
||||
v-for="item in habitats"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:to="`/habitats/${item.id}`"
|
||||
:icon="iconHabitat"
|
||||
:image="habitatCardImage(item)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HabitatEdit v-if="showEditor" />
|
||||
|
||||
@@ -37,6 +37,10 @@ const customization = computed(() => {
|
||||
].filter(Boolean);
|
||||
});
|
||||
|
||||
function imageFileName(path: string): string {
|
||||
return path.split('/').at(-1) ?? t('media.image');
|
||||
}
|
||||
|
||||
async function loadItemDetail() {
|
||||
item.value = await api.itemDetail(String(route.params.id));
|
||||
}
|
||||
@@ -136,6 +140,21 @@ watch(
|
||||
<Tabs id="item-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||
|
||||
<div v-if="detailTab === 'details'" class="detail-grid">
|
||||
<DetailSection v-if="item.image || item.imageHistory.length" :title="t('media.image')">
|
||||
<div class="entity-detail-image">
|
||||
<div v-if="item.image" class="entity-detail-image__frame">
|
||||
<img :src="item.image.url" :alt="t('media.imageAlt', { name: item.name })" />
|
||||
</div>
|
||||
<p v-else class="meta-line">{{ t('media.imageEmpty') }}</p>
|
||||
<div v-if="item.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
|
||||
<div v-for="image in item.imageHistory" :key="image.path" class="image-history-list__item">
|
||||
<img :src="image.url" :alt="t('media.imageAlt', { name: item.name })" loading="lazy" />
|
||||
<span>{{ imageFileName(image.path) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
||||
<EntityChips :items="item.acquisitionMethods" />
|
||||
</DetailSection>
|
||||
|
||||
@@ -3,19 +3,22 @@ import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import ImageUploadField from '../components/ImageUploadField.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconCancel, iconSave } from '../icons';
|
||||
import { api, type ConfigType, type ItemPayload, type Language, type Options, type TranslationMap } from '../services/api';
|
||||
import { api, type ConfigType, type EntityImage, type EntityImageUpload, type ItemPayload, type Language, type Options, type TranslationMap } from '../services/api';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { locale, t } = useI18n();
|
||||
const options = ref<Options | null>(null);
|
||||
const languages = ref<Language[]>([]);
|
||||
const currentImage = ref<EntityImage | null>(null);
|
||||
const imageHistory = ref<EntityImageUpload[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
@@ -30,7 +33,8 @@ const itemForm = ref({
|
||||
patternEditable: false,
|
||||
noRecipe: false,
|
||||
acquisitionMethodIds: [] as string[],
|
||||
tagIds: [] as string[]
|
||||
tagIds: [] as string[],
|
||||
imagePath: ''
|
||||
});
|
||||
|
||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||
@@ -42,6 +46,7 @@ const pageTitle = computed(() =>
|
||||
);
|
||||
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
|
||||
const hasRecipe = ref(false);
|
||||
const imageEntityName = computed(() => itemNameForSave().trim());
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
@@ -88,8 +93,11 @@ async function loadEditor() {
|
||||
patternEditable: item.customization.patternEditable,
|
||||
noRecipe: item.noRecipe,
|
||||
acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)),
|
||||
tagIds: item.tags.map((tag) => String(tag.id))
|
||||
tagIds: item.tags.map((tag) => String(tag.id)),
|
||||
imagePath: item.image?.path ?? ''
|
||||
};
|
||||
currentImage.value = item.image;
|
||||
imageHistory.value = item.imageHistory;
|
||||
hasRecipe.value = item.recipe !== null;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -151,7 +159,8 @@ async function saveItem() {
|
||||
patternEditable: itemForm.value.patternEditable,
|
||||
noRecipe: itemForm.value.noRecipe,
|
||||
acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds),
|
||||
tagIds: toIds(itemForm.value.tagIds)
|
||||
tagIds: toIds(itemForm.value.tagIds),
|
||||
imagePath: itemForm.value.imagePath
|
||||
};
|
||||
const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload);
|
||||
await router.push(`/items/${saved.id}`);
|
||||
@@ -162,6 +171,15 @@ async function saveItem() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageSelected(image: EntityImage) {
|
||||
currentImage.value = image;
|
||||
}
|
||||
|
||||
function handleImageUploaded(image: EntityImageUpload) {
|
||||
currentImage.value = image;
|
||||
imageHistory.value = [image, ...imageHistory.value.filter((item) => item.path !== image.path)];
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadEditor();
|
||||
});
|
||||
@@ -182,6 +200,20 @@ onMounted(() => {
|
||||
required
|
||||
/>
|
||||
|
||||
<ImageUploadField
|
||||
v-model="itemForm.imagePath"
|
||||
entity-type="items"
|
||||
:entity-id="isEditing ? routeId : null"
|
||||
:entity-name="imageEntityName"
|
||||
:label="t('media.image')"
|
||||
:current-image="currentImage"
|
||||
:history="imageHistory"
|
||||
:disabled="busy"
|
||||
@selected="handleImageSelected"
|
||||
@uploaded="handleImageUploaded"
|
||||
@error="message = $event"
|
||||
/>
|
||||
|
||||
<div class="field">
|
||||
<label for="item-category">{{ t('pages.items.category') }}</label>
|
||||
<TagsSelect
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EditMeta from '../components/EditMeta.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
@@ -42,6 +40,10 @@ const itemQuery = computed(() => ({
|
||||
}));
|
||||
const showEditor = computed(() => route.name === 'item-new');
|
||||
|
||||
function itemCardImage(item: Item) {
|
||||
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true;
|
||||
items.value = await api.items(itemQuery.value);
|
||||
@@ -112,36 +114,26 @@ watch(itemQuery, loadItems);
|
||||
</div>
|
||||
</FilterPanel>
|
||||
|
||||
<div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.items.loadingList')">
|
||||
<div v-if="loading" class="entity-grid catalog-card-grid" aria-busy="true" :aria-label="t('pages.items.loadingList')">
|
||||
<article v-for="index in skeletonCardCount" :key="`item-skeleton-${index}`" class="entity-card entity-card--skeleton">
|
||||
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
||||
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||
<div class="entity-card__content">
|
||||
<Skeleton width="72%" height="24px" />
|
||||
<Skeleton width="52%" />
|
||||
<Skeleton width="64%" />
|
||||
<div class="skeleton-chip-row">
|
||||
<Skeleton
|
||||
v-for="chipIndex in 3"
|
||||
:key="chipIndex"
|
||||
:width="chipIndex === 1 ? '74px' : '58px'"
|
||||
class="skeleton-chip"
|
||||
/>
|
||||
</div>
|
||||
<Skeleton width="128px" height="24px" />
|
||||
<Skeleton width="92px" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="entity-grid">
|
||||
<div v-else class="entity-grid catalog-card-grid">
|
||||
<EntityCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name"
|
||||
:subtitle="item.category.name"
|
||||
:to="`/items/${item.id}`"
|
||||
:icon="iconItem"
|
||||
>
|
||||
<EditMeta :entity="item" />
|
||||
<EntityChips :items="item.tags" />
|
||||
</EntityCard>
|
||||
:image="itemCardImage(item)"
|
||||
:ribbon="item.usage?.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ItemEdit v-if="showEditor" />
|
||||
|
||||
@@ -17,7 +17,7 @@ import { api, type PokemonDetail } from '../services/api';
|
||||
import PokemonEdit from './PokemonEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { locale, t } = useI18n();
|
||||
const pokemon = ref<PokemonDetail | null>(null);
|
||||
const itemCategoryTab = ref('');
|
||||
const relatedHabitatTab = ref('');
|
||||
@@ -187,11 +187,30 @@ function pokemonTypeIconSrc(typeId: number): string | null {
|
||||
}
|
||||
|
||||
function pokemonImageAlt() {
|
||||
return pokemon.value?.image ? t('pages.pokemon.imageAlt', { name: pokemon.value.name, variant: pokemon.value.image.variant }) : '';
|
||||
if (!pokemon.value?.image) {
|
||||
return '';
|
||||
}
|
||||
return pokemon.value.image.source === 'upload'
|
||||
? t('media.imageAlt', { name: pokemon.value.name })
|
||||
: t('pages.pokemon.imageAlt', { name: pokemon.value.name, variant: pokemon.value.image.variant });
|
||||
}
|
||||
|
||||
function pokemonImageLabel() {
|
||||
return pokemon.value?.image ? `${pokemon.value.image.version} - ${pokemon.value.image.variant}` : '';
|
||||
if (!pokemon.value?.image) {
|
||||
return '';
|
||||
}
|
||||
return pokemon.value.image.source === 'upload' ? t('media.uploadedImage') : `${pokemon.value.image.version} - ${pokemon.value.image.variant}`;
|
||||
}
|
||||
|
||||
function imageFileName(path: string): string {
|
||||
return path.split('/').at(-1) ?? t('media.image');
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function openImageModal() {
|
||||
@@ -502,8 +521,15 @@ watch(
|
||||
</div>
|
||||
<div class="pokemon-image-detail__caption">
|
||||
<strong>{{ pokemonImageLabel() }}</strong>
|
||||
<span>{{ pokemon.image.style }}</span>
|
||||
<p>{{ pokemon.image.description }}</p>
|
||||
<span v-if="pokemon.image.style">{{ pokemon.image.style }}</span>
|
||||
<p v-if="pokemon.image.description">{{ pokemon.image.description }}</p>
|
||||
<div v-if="pokemon.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
|
||||
<div v-for="image in pokemon.imageHistory" :key="image.path" class="image-history-list__item">
|
||||
<img :src="image.url" :alt="t('media.imageAlt', { name: pokemon.name })" loading="lazy" />
|
||||
<span>{{ imageFileName(image.path) }}</span>
|
||||
<span>{{ formatDateTime(image.uploadedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import ImageUploadField from '../components/ImageUploadField.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PokemonStatsFields from '../components/PokemonStatsFields.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
@@ -14,6 +15,8 @@ import { iconCancel, iconSave, iconSearch } from '../icons';
|
||||
import {
|
||||
api,
|
||||
type ConfigType,
|
||||
type EntityImage,
|
||||
type EntityImageUpload,
|
||||
type Language,
|
||||
type NamedEntity,
|
||||
type Options,
|
||||
@@ -47,6 +50,7 @@ const fetchIdentifier = ref('');
|
||||
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
||||
const imageOptions = ref<PokemonImage[]>([]);
|
||||
const currentPokemonImage = ref<PokemonImage | null>(null);
|
||||
const imageHistory = ref<EntityImageUpload[]>([]);
|
||||
const creatingSelect = ref('');
|
||||
const activeEditTab = ref('basic');
|
||||
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||
@@ -102,6 +106,7 @@ const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFe
|
||||
const heightMetersValue = computed(() => roundMeasurement(pokemonForm.value.heightInches * 0.0254, 2));
|
||||
const weightPoundsValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds, 1));
|
||||
const weightKgValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds * 0.45359237, 2));
|
||||
const imageEntityName = computed(() => pokemonNameForSave().trim());
|
||||
const selectedPokemonImage = computed(() => {
|
||||
const imagePath = pokemonForm.value.imagePath;
|
||||
if (!imagePath) {
|
||||
@@ -118,6 +123,7 @@ const displayedImageOptions = computed(() => {
|
||||
|
||||
return [selectedImage, ...imageOptions.value];
|
||||
});
|
||||
const selectedUploadImage = computed(() => (selectedPokemonImage.value?.source === 'upload' ? selectedPokemonImage.value : null));
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
@@ -287,6 +293,7 @@ async function loadEditor() {
|
||||
};
|
||||
currentPokemonImage.value = pokemon.image;
|
||||
imageOptions.value = pokemon.image ? [pokemon.image] : [];
|
||||
imageHistory.value = pokemon.imageHistory;
|
||||
syncSkillItemDrops();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -394,12 +401,15 @@ function fetchPokemonFromInput() {
|
||||
}
|
||||
|
||||
function pokemonImageLabel(image: PokemonImage) {
|
||||
if (image.source === 'upload') {
|
||||
return t('media.uploadedImage');
|
||||
}
|
||||
return `${image.version} - ${image.variant}`;
|
||||
}
|
||||
|
||||
function pokemonImageAlt(image: PokemonImage) {
|
||||
const name = pokemonForm.value.name.trim() || (pokemonForm.value.id.trim() ? `#${pokemonForm.value.id.trim()}` : t('pages.pokemon.title'));
|
||||
return t('pages.pokemon.imageAlt', { name, variant: image.variant });
|
||||
return image.source === 'upload' ? t('media.imageAlt', { name }) : t('pages.pokemon.imageAlt', { name, variant: image.variant });
|
||||
}
|
||||
|
||||
function selectPokemonImage(image: PokemonImage) {
|
||||
@@ -412,6 +422,27 @@ function clearPokemonImage() {
|
||||
currentPokemonImage.value = null;
|
||||
}
|
||||
|
||||
function pokemonImageFromUpload(image: EntityImage): PokemonImage {
|
||||
return {
|
||||
path: image.path,
|
||||
url: image.url,
|
||||
style: t('media.uploadedImage'),
|
||||
version: t('media.uploadedImage'),
|
||||
variant: imageEntityName.value || (pokemonForm.value.id.trim() ? `#${pokemonForm.value.id.trim()}` : t('pages.pokemon.title')),
|
||||
description: '',
|
||||
source: 'upload'
|
||||
};
|
||||
}
|
||||
|
||||
function handleUploadImageSelected(image: EntityImage) {
|
||||
selectPokemonImage(pokemonImageFromUpload(image));
|
||||
}
|
||||
|
||||
function handleUploadImageUploaded(image: EntityImageUpload) {
|
||||
imageHistory.value = [image, ...imageHistory.value.filter((item) => item.path !== image.path)];
|
||||
selectPokemonImage(pokemonImageFromUpload(image));
|
||||
}
|
||||
|
||||
async function fetchPokemonImages() {
|
||||
const identifier = fetchIdentifier.value.trim() || pokemonForm.value.id.trim();
|
||||
if (!identifier) {
|
||||
@@ -716,6 +747,21 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
</div>
|
||||
|
||||
<p v-else class="meta-line">{{ t('pages.pokemon.imageEmpty') }}</p>
|
||||
|
||||
<ImageUploadField
|
||||
v-model="pokemonForm.imagePath"
|
||||
entity-type="pokemon"
|
||||
:entity-id="isEditing ? routeId : null"
|
||||
:entity-name="imageEntityName"
|
||||
:label="t('media.imageHistory')"
|
||||
:current-image="selectedUploadImage"
|
||||
:history="imageHistory"
|
||||
:disabled="busy || imageBusy"
|
||||
:show-preview="false"
|
||||
@selected="handleUploadImageSelected"
|
||||
@uploaded="handleUploadImageUploaded"
|
||||
@error="message = $event"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-else class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabAdvance')">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EditMeta from '../components/EditMeta.vue';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
@@ -46,8 +45,8 @@ function recipeTarget(item: Item) {
|
||||
return item.recipe ? `/recipes/${item.recipe.id}` : undefined;
|
||||
}
|
||||
|
||||
function itemSubtitle(item: Item) {
|
||||
return item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name;
|
||||
function recipeCardImage(item: Item) {
|
||||
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
|
||||
}
|
||||
|
||||
function createRecipeTarget(item: Item) {
|
||||
@@ -132,31 +131,55 @@ watch(itemQuery, loadItems);
|
||||
</div>
|
||||
</FilterPanel>
|
||||
|
||||
<div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.recipes.loadingList')">
|
||||
<div v-if="loading" class="entity-grid catalog-card-grid" aria-busy="true" :aria-label="t('pages.recipes.loadingList')">
|
||||
<article v-for="index in skeletonCardCount" :key="`recipe-skeleton-${index}`" class="entity-card entity-card--skeleton">
|
||||
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
||||
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||
<div class="entity-card__content">
|
||||
<Skeleton width="72%" height="24px" />
|
||||
<Skeleton width="52%" />
|
||||
<Skeleton width="64%" />
|
||||
<Skeleton width="128px" height="24px" />
|
||||
<Skeleton variant="box" width="132px" height="36px" />
|
||||
<Skeleton width="92px" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="entity-grid">
|
||||
<div v-else class="entity-grid catalog-card-grid">
|
||||
<EntityCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:subtitle="itemSubtitle(item)"
|
||||
:subtitle="item.category.name"
|
||||
:to="recipeTarget(item)"
|
||||
:icon="itemIcon(item)"
|
||||
:image="recipeCardImage(item)"
|
||||
:ribbon="item.usage?.name"
|
||||
>
|
||||
<EditMeta v-if="item.recipe" :entity="item.recipe" />
|
||||
<RouterLink v-else-if="!item.noRecipe" class="ui-button ui-button--primary ui-button--small" :to="createRecipeTarget(item)">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.items.createRecipe') }}
|
||||
</RouterLink>
|
||||
<template #after-title>
|
||||
<span
|
||||
v-if="item.recipe"
|
||||
class="ui-button ui-button--primary ui-button--small catalog-card-action catalog-card-action--hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.items.createRecipe') }}
|
||||
</span>
|
||||
<button
|
||||
v-else-if="item.noRecipe"
|
||||
class="ui-button ui-button--primary ui-button--small catalog-card-action"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.items.createRecipe') }}
|
||||
</button>
|
||||
<RouterLink
|
||||
v-else
|
||||
class="ui-button ui-button--primary ui-button--small catalog-card-action"
|
||||
:to="createRecipeTarget(item)"
|
||||
>
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.items.createRecipe') }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</EntityCard>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user