Add dynamic meta tags for routes and entity detail pages Generate sitemap.xml and robots.txt dynamically in Vite Change default frontend port from 3000 to 20015
306 lines
11 KiB
Vue
306 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { Icon } from '@iconify/vue';
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useRoute } from 'vue-router';
|
|
import DetailSection from '../components/DetailSection.vue';
|
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
|
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
|
import EntityChips from '../components/EntityChips.vue';
|
|
import PageHeader from '../components/PageHeader.vue';
|
|
import PokeBallMark from '../components/PokeBallMark.vue';
|
|
import Skeleton from '../components/Skeleton.vue';
|
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
|
import { iconBack, iconEdit, iconHabitat } from '../icons';
|
|
import { applySeo } from '../seo';
|
|
import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api';
|
|
import HabitatEdit from './HabitatEdit.vue';
|
|
|
|
const route = useRoute();
|
|
const { t } = useI18n();
|
|
const habitat = ref<HabitatDetail | null>(null);
|
|
const currentUser = ref<AuthUser | null>(null);
|
|
const detailTab = ref('details');
|
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
|
const weathers = ['晴天', '阴天', '雨天'];
|
|
const showEditor = computed(() => route.name === 'habitat-edit');
|
|
const canUpdateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.update') === true);
|
|
const detailTabs = computed<TabOption[]>(() => [
|
|
{ value: 'details', label: t('common.details') },
|
|
{ value: 'discussion', label: t('discussion.title') },
|
|
{ value: 'history', label: t('history.editHistory') }
|
|
]);
|
|
|
|
type PokemonRow = {
|
|
id: number;
|
|
name: string;
|
|
image: HabitatDetail['pokemon'][number]['image'];
|
|
timeOfDays: string[];
|
|
weathers: string[];
|
|
rarity: number;
|
|
maps: string[];
|
|
};
|
|
|
|
function sortByOrder(values: Set<string>, order: string[]) {
|
|
return [...values].sort((a, b) => {
|
|
const indexA = order.indexOf(a);
|
|
const indexB = order.indexOf(b);
|
|
if (indexA === -1 && indexB === -1) return a.localeCompare(b);
|
|
if (indexA === -1) return 1;
|
|
if (indexB === -1) return -1;
|
|
return indexA - indexB;
|
|
});
|
|
}
|
|
|
|
function timeLabel(value: string): string {
|
|
const labels: Record<string, string> = {
|
|
早晨: t('appearance.morning'),
|
|
中午: t('appearance.noon'),
|
|
傍晚: t('appearance.evening'),
|
|
晚上: t('appearance.night')
|
|
};
|
|
return labels[value] ?? value;
|
|
}
|
|
|
|
function weatherLabel(value: string): string {
|
|
const labels: Record<string, string> = {
|
|
晴天: t('appearance.sunny'),
|
|
阴天: t('appearance.cloudy'),
|
|
雨天: t('appearance.rainy')
|
|
};
|
|
return labels[value] ?? value;
|
|
}
|
|
|
|
const pokemonRows = computed<PokemonRow[]>(() => {
|
|
if (!habitat.value) return [];
|
|
|
|
const rows = new Map<
|
|
string,
|
|
{
|
|
id: number;
|
|
name: string;
|
|
image: HabitatDetail['pokemon'][number]['image'];
|
|
timeOfDays: Set<string>;
|
|
weathers: Set<string>;
|
|
rarity: number;
|
|
maps: Set<string>;
|
|
}
|
|
>();
|
|
|
|
habitat.value.pokemon.forEach((pokemon) => {
|
|
const key = `${pokemon.id}:${pokemon.rarity}`;
|
|
const row = rows.get(key) ?? {
|
|
id: pokemon.id,
|
|
name: pokemon.name,
|
|
image: pokemon.image,
|
|
timeOfDays: new Set<string>(),
|
|
weathers: new Set<string>(),
|
|
rarity: pokemon.rarity,
|
|
maps: new Set<string>()
|
|
};
|
|
|
|
row.timeOfDays.add(pokemon.time_of_day);
|
|
row.weathers.add(pokemon.weather);
|
|
row.maps.add(pokemon.map.name);
|
|
rows.set(key, row);
|
|
});
|
|
|
|
return [...rows.values()].map((row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
image: row.image,
|
|
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
|
|
weathers: sortByOrder(row.weathers, weathers),
|
|
rarity: row.rarity,
|
|
maps: [...row.maps]
|
|
}));
|
|
});
|
|
|
|
async function loadHabitatDetail() {
|
|
const nextHabitat = await api.habitatDetail(String(route.params.id));
|
|
habitat.value = nextHabitat;
|
|
|
|
if (route.meta.editorModal !== true) {
|
|
applySeo({
|
|
title: `${nextHabitat.name} - ${t('pages.habitats.title')}`,
|
|
description: t('seo.habitatDetailDescription', { name: nextHabitat.name }),
|
|
canonicalPath: `/habitats/${nextHabitat.id}`,
|
|
image: nextHabitat.image?.url
|
|
});
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (getAuthToken()) {
|
|
try {
|
|
currentUser.value = (await api.me()).user;
|
|
} catch {
|
|
currentUser.value = null;
|
|
}
|
|
}
|
|
await loadHabitatDetail();
|
|
});
|
|
|
|
watch(
|
|
() => route.name,
|
|
(name, oldName) => {
|
|
if (oldName === 'habitat-edit' && name === 'habitat-detail') {
|
|
void loadHabitatDetail();
|
|
}
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => route.params.id,
|
|
() => {
|
|
habitat.value = null;
|
|
detailTab.value = 'details';
|
|
void loadHabitatDetail();
|
|
}
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<section v-if="!habitat" class="page-stack" aria-busy="true" :aria-label="t('pages.habitats.loadingDetail')">
|
|
<div class="page-header page-header--skeleton" aria-hidden="true">
|
|
<div class="page-header__copy">
|
|
<Skeleton width="132px" />
|
|
<Skeleton width="240px" height="46px" />
|
|
<Skeleton width="120px" />
|
|
<Skeleton width="300px" />
|
|
</div>
|
|
<div class="page-header__actions">
|
|
<Skeleton variant="box" width="88px" height="36px" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="habitat-detail-stack" aria-hidden="true">
|
|
<section class="detail-section skeleton-detail-section">
|
|
<div class="detail-section__header">
|
|
<Skeleton width="92px" height="24px" />
|
|
</div>
|
|
<div class="detail-section__body">
|
|
<div class="skeleton-chip-row">
|
|
<Skeleton v-for="index in 4" :key="index" width="76px" class="skeleton-chip" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="detail-section skeleton-detail-section">
|
|
<div class="detail-section__header">
|
|
<Skeleton width="148px" height="24px" />
|
|
</div>
|
|
<div class="detail-section__body">
|
|
<ul class="row-list appearance-list skeleton-row-list">
|
|
<li v-for="index in 3" :key="index" class="skeleton-appearance-row">
|
|
<Skeleton width="104px" />
|
|
<div class="skeleton-summary">
|
|
<div v-for="line in 4" :key="line">
|
|
<Skeleton width="56px" />
|
|
<Skeleton :width="line === 4 ? '72%' : '46%'" />
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
<section v-else class="page-stack">
|
|
<PageHeader :title="habitat.name" :subtitle="t('pages.habitats.detailSubtitle')">
|
|
<template #kicker>{{ t('pages.habitats.detailKicker') }}</template>
|
|
<template #actions>
|
|
<RouterLink v-if="canUpdateHabitat" class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
|
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
|
{{ t('common.edit') }}
|
|
</RouterLink>
|
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/habitats">
|
|
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
|
|
{{ t('common.backToList') }}
|
|
</RouterLink>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<div class="detail-tabs">
|
|
<Tabs id="habitat-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
|
|
|
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
|
|
<div class="entity-profile-grid">
|
|
<section class="detail-section entity-profile-media-section" :aria-label="t('media.image')">
|
|
<div class="entity-detail-image">
|
|
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !habitat.image }">
|
|
<img v-if="habitat.image" :src="habitat.image.url" :alt="t('media.imageAlt', { name: habitat.name })" />
|
|
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
|
|
<Icon :icon="iconHabitat" class="entity-card__icon" aria-hidden="true" />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="entity-profile-main">
|
|
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
|
|
<dl class="entity-profile-facts">
|
|
<div>
|
|
<dt>{{ t('pages.habitats.recipeList') }}</dt>
|
|
<dd>{{ habitat.recipe.length }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('pages.habitats.possiblePokemon') }}</dt>
|
|
<dd>{{ pokemonRows.length }}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div class="entity-profile-group">
|
|
<h3 class="section-subtitle">{{ t('pages.habitats.recipeList') }}</h3>
|
|
<EntityChips v-if="habitat.recipe.length" :items="habitat.recipe" />
|
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<DetailSection :title="t('pages.habitats.possiblePokemon')">
|
|
<ul v-if="pokemonRows.length" class="row-list appearance-list appearance-list--with-media">
|
|
<li v-for="item in pokemonRows" :key="`${item.id}-${item.rarity}`">
|
|
<span class="related-entity-media related-entity-media--appearance related-entity-media--pokemon" aria-hidden="true">
|
|
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
|
|
<PokeBallMark v-else size="24px" />
|
|
</span>
|
|
<RouterLink class="appearance-name" :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
|
|
<dl class="appearance-summary">
|
|
<div>
|
|
<dt>{{ t('appearance.time') }}</dt>
|
|
<dd>{{ item.timeOfDays.map(timeLabel).join(' / ') }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('appearance.weather') }}</dt>
|
|
<dd>{{ item.weathers.map(weatherLabel).join(' / ') }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('appearance.rarity') }}</dt>
|
|
<dd>{{ t('appearance.stars', { count: item.rarity }) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>{{ t('appearance.maps') }}</dt>
|
|
<dd>{{ item.maps.join(' / ') }}</dd>
|
|
</div>
|
|
</dl>
|
|
</li>
|
|
</ul>
|
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
|
</DetailSection>
|
|
</div>
|
|
|
|
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
|
<EntityDiscussionPanel entity-type="habitats" :entity-id="habitat.id" />
|
|
</div>
|
|
|
|
<div v-else class="detail-tab-panel">
|
|
<EditHistoryPanel :entity="habitat" :history="habitat.editHistory" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<HabitatEdit v-if="showEditor" />
|
|
</template>
|