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:
2026-05-03 01:08:45 +08:00
parent 36e10a06b0
commit 784cbdacd1
23 changed files with 1407 additions and 102 deletions

View File

@@ -10,11 +10,13 @@ defineProps<{
icon?: AppIcon;
marker?: string;
image?: { src: string; alt: string };
ribbon?: string;
}>();
</script>
<template>
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
<span v-if="ribbon" class="entity-card__ribbon">{{ ribbon }}</span>
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
@@ -23,12 +25,14 @@ defineProps<{
</span>
<div class="entity-card__content">
<span class="entity-card__title">{{ title }}</span>
<slot name="after-title"></slot>
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
<slot></slot>
</div>
</RouterLink>
<article v-else class="entity-card">
<span v-if="ribbon" class="entity-card__ribbon">{{ ribbon }}</span>
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
@@ -37,6 +41,7 @@ defineProps<{
</span>
<div class="entity-card__content">
<span class="entity-card__title">{{ title }}</span>
<slot name="after-title"></slot>
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
<slot></slot>
</div>

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { iconCancel, iconImage, iconUpload } from '../icons';
import { api, type EntityImage, type EntityImageUpload, type ImageUploadEntityType } from '../services/api';
const props = withDefaults(
defineProps<{
modelValue: string;
entityType: ImageUploadEntityType;
entityId?: string | number | null;
entityName: string;
label?: string;
currentImage?: EntityImage | null;
history?: EntityImageUpload[];
disabled?: boolean;
showPreview?: boolean;
}>(),
{
label: '',
currentImage: null,
history: () => [],
disabled: false,
showPreview: true
}
);
const emit = defineEmits<{
'update:modelValue': [value: string];
uploaded: [image: EntityImageUpload];
selected: [image: EntityImage];
error: [message: string];
}>();
const { t } = useI18n();
const fileInput = ref<HTMLInputElement | null>(null);
const uploadBusy = ref(false);
const localUploads = ref<EntityImageUpload[]>([]);
const imageLabel = computed(() => props.label || t('media.image'));
const uploadDisabled = computed(() => props.disabled || uploadBusy.value || props.entityName.trim() === '');
const imageOptions = computed<EntityImage[]>(() => {
const images = [
...localUploads.value,
...(props.history ?? []),
...(props.currentImage ? [props.currentImage] : [])
];
const seen = new Set<string>();
return images.filter((image) => {
if (!image.path || seen.has(image.path)) {
return false;
}
seen.add(image.path);
return true;
});
});
const selectedImage = computed(() => {
if (!props.modelValue) {
return null;
}
return imageOptions.value.find((image) => image.path === props.modelValue) ?? props.currentImage ?? null;
});
function imageName(image: EntityImage): string {
const parts = image.path.split('/');
return parts.at(-1) ?? t('media.image');
}
function openFilePicker() {
if (!uploadDisabled.value) {
fileInput.value?.click();
}
}
function selectImage(image: EntityImage) {
emit('update:modelValue', image.path);
emit('selected', image);
}
function clearImage() {
emit('update:modelValue', '');
}
async function uploadImage(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
uploadBusy.value = true;
try {
const uploaded = await api.uploadImage(props.entityType, {
file,
entityName: props.entityName,
entityId: props.entityId
});
localUploads.value = [uploaded, ...localUploads.value];
emit('uploaded', uploaded);
selectImage(uploaded);
} catch (error) {
emit('error', error instanceof Error && error.message ? error.message : t('media.uploadFailed'));
} finally {
uploadBusy.value = false;
input.value = '';
}
}
</script>
<template>
<section class="image-upload-field field">
<div class="image-upload-field__header">
<span class="field-label">{{ imageLabel }}</span>
<div class="image-upload-field__actions">
<input
ref="fileInput"
class="image-upload-field__input"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
:disabled="uploadDisabled"
@change="uploadImage"
/>
<button type="button" class="ui-button ui-button--blue ui-button--small" :disabled="uploadDisabled" @click="openFilePicker">
<Icon :icon="iconUpload" class="ui-icon" aria-hidden="true" />
{{ uploadBusy ? t('media.uploading') : t('media.uploadImage') }}
</button>
<button v-if="modelValue" type="button" class="plain-button ui-button--small" :disabled="disabled || uploadBusy" @click="clearImage">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('media.clearImage') }}
</button>
</div>
</div>
<div v-if="showPreview && selectedImage" class="pokemon-image-preview image-upload-field__preview" :aria-label="t('media.selectedImage')">
<div class="pokemon-image-preview__screen">
<img :src="selectedImage.url" :alt="t('media.imageAlt', { name: entityName })" />
</div>
<div class="pokemon-image-preview__caption">
<strong>{{ t('media.selectedImage') }}</strong>
<span>{{ imageName(selectedImage) }}</span>
</div>
</div>
<p v-else-if="showPreview" class="meta-line">{{ t('media.imageEmpty') }}</p>
<div v-if="imageOptions.length" class="pokemon-image-thumbnails image-upload-field__history" :aria-label="t('media.imageHistory')">
<button
v-for="image in imageOptions"
:key="image.path"
type="button"
class="pokemon-image-thumbnail"
:class="{ active: image.path === modelValue }"
:aria-pressed="image.path === modelValue"
:disabled="disabled || uploadBusy"
@click="selectImage(image)"
>
<img :src="image.url" :alt="t('media.imageAlt', { name: entityName })" loading="lazy" />
<span>{{ imageName(image) }}</span>
</button>
</div>
<p v-else class="meta-line image-upload-field__empty">
<Icon :icon="iconImage" class="ui-icon" aria-hidden="true" />
{{ t('media.imageHistoryEmpty') }}
</p>
</section>
</template>

View File

@@ -18,6 +18,7 @@ export const iconEdit: AppIcon = 'mdi:pencil-outline';
export const iconError: AppIcon = 'mdi:close-circle-outline';
export const iconEvent: AppIcon = 'mdi:calendar-star';
export const iconHabitat: AppIcon = 'mdi:pine-tree';
export const iconImage: AppIcon = 'mdi:image-outline';
export const iconInfo: AppIcon = 'mdi:information-outline';
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
export const iconKey: AppIcon = 'mdi:key-outline';
@@ -41,4 +42,5 @@ export const iconSave: AppIcon = 'mdi:content-save-outline';
export const iconSearch: AppIcon = 'mdi:magnify';
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
export const iconTranslate: AppIcon = 'mdi:translate';
export const iconUpload: AppIcon = 'mdi:upload-outline';
export const iconWarning: AppIcon = 'mdi:alert-outline';

View File

@@ -50,18 +50,36 @@ export interface PokemonStats {
speed: number;
}
export interface PokemonImage {
export interface UserSummary {
id: number;
displayName: string;
}
export interface EntityImage {
path: string;
url: string;
}
export interface EntityImageUpload extends EntityImage {
id: number;
entityType: ImageUploadEntityType;
entityId: number | null;
entityName: string;
originalFilename: string;
mimeType: string;
byteSize: number;
uploadedAt: string;
uploadedBy: UserSummary | null;
}
export type ImageUploadEntityType = 'pokemon' | 'items' | 'habitats';
export interface PokemonImage extends EntityImage {
style: string;
version: string;
variant: string;
description: string;
}
export interface UserSummary {
id: number;
displayName: string;
source?: 'sprite' | 'upload';
}
export interface EditInfo {
@@ -120,6 +138,7 @@ export interface PokemonDetail extends Pokemon {
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
relatedPokemon: RelatedPokemon[];
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
habitats: Array<{
id: number;
name: string;
@@ -135,12 +154,14 @@ export interface Habitat extends EditInfo {
name: string;
baseName?: string;
translations?: TranslationMap;
image: EntityImage | null;
recipe: Array<NamedEntity & { quantity: number }>;
pokemon?: NamedEntity[];
}
export interface HabitatDetail extends Habitat {
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
pokemon: Array<NamedEntity & {
time_of_day: string;
weather: string;
@@ -170,6 +191,7 @@ export interface Item extends EditInfo {
name: string;
baseName?: string;
translations?: TranslationMap;
image: EntityImage | null;
category: NamedEntity;
usage: NamedEntity | null;
customization: {
@@ -188,6 +210,7 @@ export interface ItemDetail extends Item {
relatedRecipes: RecipeUsage[];
relatedHabitats: HabitatUsage[];
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
droppedByPokemon: Array<{
pokemon: NamedEntity;
skill: NamedEntity;
@@ -356,6 +379,7 @@ export interface ItemPayload {
noRecipe: boolean;
acquisitionMethodIds: number[];
tagIds: number[];
imagePath: string;
}
export interface RecipePayload {
@@ -367,6 +391,7 @@ export interface RecipePayload {
export interface HabitatPayload {
name: string;
translations?: TranslationMap;
imagePath: string;
recipeItems: Array<{ itemId: number; quantity: number }>;
pokemonAppearances: Array<{
pokemonId: number;
@@ -518,6 +543,20 @@ async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body:
return response.json() as Promise<T>;
}
async function sendFormData<T>(path: string, body: FormData): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'POST',
headers: requestHeaders(),
body
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
}
return response.json() as Promise<T>;
}
async function postEmpty(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'POST',
@@ -614,6 +653,18 @@ export const api = {
payload: EntityDiscussionCommentPayload
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
uploadImage: (
entityType: ImageUploadEntityType,
payload: { file: File; entityName: string; entityId?: string | number | null }
) => {
const body = new FormData();
body.set('entityName', payload.entityName);
if (payload.entityId) {
body.set('entityId', String(payload.entityId));
}
body.set('file', payload.file);
return sendFormData<EntityImageUpload>(`/api/uploads/${entityType}`, body);
},
createDailyChecklistItem: (payload: DailyChecklistPayload) =>
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>

View File

@@ -960,6 +960,49 @@ button:disabled,
justify-self: start;
}
.image-upload-field {
gap: 12px;
}
.image-upload-field__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.image-upload-field__actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.image-upload-field__input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.image-upload-field__preview .pokemon-image-preview__screen {
min-height: 180px;
}
.image-upload-field__preview .pokemon-image-preview__screen img {
max-height: 180px;
}
.image-upload-field__empty {
display: inline-flex;
align-items: center;
gap: 6px;
}
.pokemon-edit-panel {
min-height: 0;
display: grid;
@@ -1414,6 +1457,7 @@ button:disabled,
}
.entity-card {
position: relative;
min-height: 164px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -1424,6 +1468,7 @@ button:disabled,
background: var(--surface);
box-shadow: var(--shadow-control);
color: var(--ink);
overflow: hidden;
}
.entity-card--link {
@@ -1471,6 +1516,29 @@ button:disabled,
object-fit: contain;
}
.entity-card__ribbon {
position: absolute;
z-index: 1;
top: 14px;
left: -38px;
width: 132px;
min-height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
transform: rotate(-35deg);
border: 2px solid var(--line-strong);
background: var(--pokemon-blue);
color: #ffffff;
box-shadow: 0 2px 0 var(--line-strong);
font-size: 0.72rem;
font-weight: 950;
line-height: 1;
pointer-events: none;
text-align: center;
}
.entity-card__content {
display: grid;
align-content: start;
@@ -1493,6 +1561,16 @@ button:disabled,
color: var(--muted);
}
.catalog-card-grid .entity-card {
min-height: 224px;
grid-template-columns: 1fr;
justify-items: center;
align-content: start;
gap: 14px;
padding: 18px 16px 16px;
text-align: center;
}
.pokemon-list-grid .entity-card {
min-height: 168px;
grid-template-columns: 1fr;
@@ -1502,24 +1580,50 @@ button:disabled,
text-align: center;
}
.pokemon-list-grid .entity-card__mark {
.pokemon-list-grid .entity-card__mark,
.catalog-card-grid .entity-card__mark {
width: 92px;
height: 92px;
}
.pokemon-list-grid .pokeball-mark {
.pokemon-list-grid .pokeball-mark,
.catalog-card-grid .pokeball-mark {
--ball-size: 64px !important;
}
.catalog-card-grid .entity-card__content {
justify-items: center;
gap: 7px;
}
.pokemon-list-grid .entity-card__content {
justify-items: center;
gap: 0;
}
.pokemon-list-grid .entity-card__title {
.pokemon-list-grid .entity-card__title,
.catalog-card-grid .entity-card__title {
font-size: 20px;
}
.catalog-card-grid .entity-card__subtitle {
min-height: 20px;
font-weight: 850;
}
.catalog-card-action {
min-height: 36px;
max-width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: normal;
}
.catalog-card-action--hidden {
visibility: hidden;
}
.edit-meta {
margin: 0;
color: var(--muted);
@@ -3379,6 +3483,59 @@ button:disabled,
white-space: pre-wrap;
}
.entity-detail-image {
display: grid;
gap: 12px;
}
.entity-detail-image__frame {
min-height: 220px;
display: grid;
place-items: center;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background:
linear-gradient(90deg, rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
linear-gradient(rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
var(--surface-soft);
}
.entity-detail-image__frame img {
width: min(100%, 360px);
max-height: 240px;
object-fit: contain;
}
.image-history-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: 10px;
}
.image-history-list__item {
display: grid;
gap: 6px;
justify-items: center;
padding: 8px;
border: 2px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
}
.image-history-list__item img {
width: 74px;
height: 64px;
object-fit: contain;
}
.image-history-list__item span {
color: var(--muted);
font-size: 0.76rem;
font-weight: 850;
text-align: center;
overflow-wrap: anywhere;
}
.pokemon-image-detail {
display: grid;
grid-template-columns: minmax(220px, 420px) minmax(0, 1fr);

View File

@@ -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>

View File

@@ -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">

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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')">

View File

@@ -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>