feat(pokemon): add types, stats, genus, dimensions, and details
Update schema and API to support expanded Pokemon profile fields Add UI for editing and displaying types, base stats, and dimensions Support translations for details and genus fields
This commit is contained in:
@@ -12,6 +12,17 @@ const changeLabelKeys: Record<string, string> = {
|
||||
Name: 'common.name',
|
||||
名字: 'common.name',
|
||||
名称: 'common.name',
|
||||
Genus: 'pages.pokemon.genus',
|
||||
Details: 'pages.pokemon.details',
|
||||
介绍: 'pages.pokemon.details',
|
||||
Height: 'pages.pokemon.height',
|
||||
身高: 'pages.pokemon.height',
|
||||
Weight: 'pages.pokemon.weight',
|
||||
体重: 'pages.pokemon.weight',
|
||||
Types: 'pages.pokemon.types',
|
||||
属性: 'pages.pokemon.types',
|
||||
Stats: 'pages.pokemon.statsTitle',
|
||||
六维: 'pages.pokemon.statsTitle',
|
||||
'Ideal Habitat': 'pages.pokemon.environment',
|
||||
'Favorite environment': 'pages.pokemon.environment',
|
||||
喜欢的环境: 'pages.pokemon.environment',
|
||||
|
||||
50
frontend/src/components/PokemonStatsFields.vue
Normal file
50
frontend/src/components/PokemonStatsFields.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PokemonStats } from '../services/api';
|
||||
|
||||
const props = defineProps<{
|
||||
idPrefix: string;
|
||||
modelValue: PokemonStats;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: PokemonStats];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const statRows: Array<{ key: keyof PokemonStats; labelKey: string }> = [
|
||||
{ key: 'hp', labelKey: 'pages.pokemon.stats.hp' },
|
||||
{ key: 'attack', labelKey: 'pages.pokemon.stats.attack' },
|
||||
{ key: 'defense', labelKey: 'pages.pokemon.stats.defense' },
|
||||
{ key: 'specialAttack', labelKey: 'pages.pokemon.stats.specialAttack' },
|
||||
{ key: 'specialDefense', labelKey: 'pages.pokemon.stats.specialDefense' },
|
||||
{ key: 'speed', labelKey: 'pages.pokemon.stats.speed' }
|
||||
];
|
||||
|
||||
function updateStat(key: keyof PokemonStats, event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = Number(input.value);
|
||||
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
[key]: Number.isInteger(value) && value >= 0 ? value : 0
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pokemon-stats-fields">
|
||||
<div v-for="stat in statRows" :key="stat.key" class="field">
|
||||
<label :for="`${idPrefix}-${stat.key}`">{{ t(stat.labelKey) }}</label>
|
||||
<input
|
||||
:id="`${idPrefix}-${stat.key}`"
|
||||
:value="modelValue[stat.key]"
|
||||
min="0"
|
||||
step="1"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
@input="updateStat(stat.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
33
frontend/src/components/PokemonStatsPanel.vue
Normal file
33
frontend/src/components/PokemonStatsPanel.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ProgressBar from './ProgressBar.vue';
|
||||
import type { PokemonStats } from '../services/api';
|
||||
|
||||
defineProps<{
|
||||
stats: PokemonStats;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const statMax = 150;
|
||||
const statRows: Array<{ key: keyof PokemonStats; labelKey: string; color: string }> = [
|
||||
{ key: 'hp', labelKey: 'pages.pokemon.stats.hp', color: 'var(--success)' },
|
||||
{ key: 'attack', labelKey: 'pages.pokemon.stats.attack', color: 'var(--pokemon-red)' },
|
||||
{ key: 'defense', labelKey: 'pages.pokemon.stats.defense', color: 'var(--pokemon-blue)' },
|
||||
{ key: 'specialAttack', labelKey: 'pages.pokemon.stats.specialAttack', color: 'var(--type-psychic)' },
|
||||
{ key: 'specialDefense', labelKey: 'pages.pokemon.stats.specialDefense', color: 'var(--type-water)' },
|
||||
{ key: 'speed', labelKey: 'pages.pokemon.stats.speed', color: 'var(--pokemon-yellow)' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pokemon-stats-panel">
|
||||
<ProgressBar
|
||||
v-for="stat in statRows"
|
||||
:key="stat.key"
|
||||
:label="t(stat.labelKey)"
|
||||
:value="stats[stat.key]"
|
||||
:max="statMax"
|
||||
:color="stat.color"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
33
frontend/src/components/ProgressBar.vue
Normal file
33
frontend/src/components/ProgressBar.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: number;
|
||||
max?: number;
|
||||
color?: string;
|
||||
}>(),
|
||||
{
|
||||
max: 100,
|
||||
color: 'var(--pokemon-blue)'
|
||||
}
|
||||
);
|
||||
|
||||
const safeMax = computed(() => (Number.isFinite(props.max) && props.max > 0 ? props.max : 100));
|
||||
const safeValue = computed(() => (Number.isFinite(props.value) && props.value > 0 ? props.value : 0));
|
||||
const percentage = computed(() => Math.min(100, Math.round((safeValue.value / safeMax.value) * 100)));
|
||||
const valueText = computed(() => `${safeValue.value} / ${safeMax.value}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="progress" role="meter" :aria-label="label" :aria-valuenow="safeValue" aria-valuemin="0" :aria-valuemax="safeMax">
|
||||
<div class="progress-label">
|
||||
<span>{{ label }}</span>
|
||||
<span>{{ valueText }}</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<span class="progress-fill" :style="{ width: `${percentage}%`, background: color }"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,6 +11,8 @@ const props = defineProps<{
|
||||
translations: TranslationMap;
|
||||
languages: Language[];
|
||||
required?: boolean;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -79,11 +81,20 @@ function updateField(language: Language, value: string) {
|
||||
{{ t('common.fieldForLanguage', { field: label, language: currentLanguage.name }) }}
|
||||
</label>
|
||||
<input
|
||||
v-if="!multiline"
|
||||
:id="`${idPrefix}-${currentLanguage.code}`"
|
||||
v-model="currentValue"
|
||||
:placeholder="currentPlaceholder"
|
||||
:required="currentRequired"
|
||||
/>
|
||||
<textarea
|
||||
v-else
|
||||
:id="`${idPrefix}-${currentLanguage.code}`"
|
||||
v-model="currentValue"
|
||||
:placeholder="currentPlaceholder"
|
||||
:required="currentRequired"
|
||||
:rows="rows ?? 4"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -87,13 +87,40 @@ const messages = {
|
||||
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
|
||||
detailKicker: 'Pokédex Detail',
|
||||
editKicker: 'Pokédex Edit',
|
||||
editSubtitle: 'Maintain Pokemon profile, specialities, and favourites.',
|
||||
editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.',
|
||||
newTitle: 'New Pokemon',
|
||||
editTitle: 'Edit #{id} {name}',
|
||||
loadingList: 'Loading Pokemon list',
|
||||
loadingDetail: 'Loading Pokemon detail',
|
||||
loadingEdit: 'Loading Pokemon editor',
|
||||
environmentPrefix: 'Ideal Habitat: {name}',
|
||||
details: 'Details',
|
||||
genus: 'Genus',
|
||||
height: 'Height',
|
||||
heightInput: 'Height (in)',
|
||||
heightImperial: 'ft / in',
|
||||
heightMetric: 'm',
|
||||
feet: 'ft',
|
||||
inches: 'in',
|
||||
meters: 'm',
|
||||
weight: 'Weight',
|
||||
weightInput: 'Weight (lb)',
|
||||
pounds: 'lb',
|
||||
kilograms: 'kg',
|
||||
measurements: 'Height & Weight',
|
||||
types: 'Types',
|
||||
typeOne: 'Type 1',
|
||||
typeTwo: 'Type 2',
|
||||
typesAndStats: 'Types & Base stats',
|
||||
statsTitle: 'Base stats',
|
||||
stats: {
|
||||
hp: 'HP',
|
||||
attack: 'Attack',
|
||||
defense: 'Defense',
|
||||
specialAttack: 'Special Attack',
|
||||
specialDefense: 'Special Defense',
|
||||
speed: 'Speed'
|
||||
},
|
||||
environment: 'Ideal Habitat',
|
||||
skills: 'Specialities',
|
||||
skillMatchMode: 'Speciality match mode',
|
||||
@@ -109,6 +136,7 @@ const messages = {
|
||||
relatedItemCategory: 'Related item category',
|
||||
habitats: 'Habitats',
|
||||
namePlaceholder: 'Name',
|
||||
searchTypes: 'Search types',
|
||||
searchEnvironment: 'Search ideal habitats',
|
||||
searchSkills: 'Search specialities',
|
||||
searchFavoriteThings: 'Search favourites',
|
||||
@@ -220,6 +248,7 @@ const messages = {
|
||||
}
|
||||
},
|
||||
config: {
|
||||
pokemonTypes: 'Pokemon Types',
|
||||
skills: 'Specialities',
|
||||
environments: 'Ideal Habitats',
|
||||
favoriteThings: 'Favourites / tags',
|
||||
@@ -341,13 +370,40 @@ const messages = {
|
||||
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
|
||||
detailKicker: 'Pokédex Detail',
|
||||
editKicker: 'Pokédex Edit',
|
||||
editSubtitle: '维护 Pokemon 基本资料、特长和喜欢的东西。',
|
||||
editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。',
|
||||
newTitle: '新增 Pokemon',
|
||||
editTitle: '编辑 #{id} {name}',
|
||||
loadingList: '正在加载 Pokemon 列表',
|
||||
loadingDetail: '正在加载 Pokemon 详情',
|
||||
loadingEdit: '正在加载 Pokemon 编辑内容',
|
||||
environmentPrefix: '喜欢的环境:{name}',
|
||||
details: '介绍',
|
||||
genus: '分类',
|
||||
height: '身高',
|
||||
heightInput: '身高(in)',
|
||||
heightImperial: 'ft / in',
|
||||
heightMetric: 'm',
|
||||
feet: 'ft',
|
||||
inches: 'in',
|
||||
meters: 'm',
|
||||
weight: '体重',
|
||||
weightInput: '体重(lb)',
|
||||
pounds: 'lb',
|
||||
kilograms: 'kg',
|
||||
measurements: '身高与体重',
|
||||
types: '属性',
|
||||
typeOne: '属性 1',
|
||||
typeTwo: '属性 2',
|
||||
typesAndStats: '属性与六维',
|
||||
statsTitle: '六维',
|
||||
stats: {
|
||||
hp: 'HP',
|
||||
attack: '攻击',
|
||||
defense: '防御',
|
||||
specialAttack: '特攻',
|
||||
specialDefense: '特防',
|
||||
speed: '速度'
|
||||
},
|
||||
environment: '喜欢的环境',
|
||||
skills: '特长',
|
||||
skillMatchMode: '特长匹配方式',
|
||||
@@ -363,6 +419,7 @@ const messages = {
|
||||
relatedItemCategory: '关联物品分类',
|
||||
habitats: '栖息地',
|
||||
namePlaceholder: '名字',
|
||||
searchTypes: '搜索属性',
|
||||
searchEnvironment: '搜索喜欢的环境',
|
||||
searchSkills: '搜索特长',
|
||||
searchFavoriteThings: '搜索喜欢的东西',
|
||||
@@ -474,6 +531,7 @@ const messages = {
|
||||
}
|
||||
},
|
||||
config: {
|
||||
pokemonTypes: 'Pokemon 属性',
|
||||
skills: '特长',
|
||||
environments: '喜欢的环境',
|
||||
favoriteThings: '喜欢的东西 / 标签',
|
||||
|
||||
@@ -4,7 +4,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
||||
const authTokenKey = 'pokopia_auth_token';
|
||||
const authChangeEvent = 'pokopia-auth-change';
|
||||
|
||||
export type TranslationField = 'name' | 'title';
|
||||
export type TranslationField = 'name' | 'title' | 'details' | 'genus';
|
||||
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
|
||||
|
||||
export interface Language {
|
||||
@@ -26,6 +26,15 @@ export interface Skill extends NamedEntity {
|
||||
hasItemDrop: boolean;
|
||||
}
|
||||
|
||||
export interface PokemonStats {
|
||||
hp: number;
|
||||
attack: number;
|
||||
defense: number;
|
||||
specialAttack: number;
|
||||
specialDefense: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
id: number;
|
||||
displayName: string;
|
||||
@@ -57,7 +66,17 @@ export interface Pokemon extends EditInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
baseName?: string;
|
||||
genus: string;
|
||||
baseGenus?: string;
|
||||
details: string;
|
||||
baseDetails?: string;
|
||||
heightInches: number;
|
||||
heightMeters: number;
|
||||
weightPounds: number;
|
||||
weightKg: number;
|
||||
translations?: TranslationMap;
|
||||
types: NamedEntity[];
|
||||
stats: PokemonStats;
|
||||
environment: NamedEntity;
|
||||
skills: Skill[];
|
||||
favorite_things: NamedEntity[];
|
||||
@@ -161,6 +180,7 @@ export interface RecipeDetail extends Recipe {
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
pokemonTypes: NamedEntity[];
|
||||
skills: Skill[];
|
||||
environments: NamedEntity[];
|
||||
favoriteThings: NamedEntity[];
|
||||
@@ -193,6 +213,7 @@ export interface AuthResponse {
|
||||
}
|
||||
|
||||
export type ConfigType =
|
||||
| 'pokemon-types'
|
||||
| 'skills'
|
||||
| 'environments'
|
||||
| 'favorite-things'
|
||||
@@ -204,7 +225,13 @@ export type ConfigType =
|
||||
export interface PokemonPayload {
|
||||
id: number;
|
||||
name: string;
|
||||
genus: string;
|
||||
details: string;
|
||||
heightInches: number;
|
||||
weightPounds: number;
|
||||
translations?: TranslationMap;
|
||||
typeIds: number[];
|
||||
stats: PokemonStats;
|
||||
environmentId: number;
|
||||
skillIds: number[];
|
||||
favoriteThingIds: number[];
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
--pokemon-blue-deep: #003a70;
|
||||
--pokemon-red: #ee1515;
|
||||
--pokemon-red-deep: #cc0000;
|
||||
--type-water: #6390f0;
|
||||
--type-psychic: #f95587;
|
||||
--pokeball-black: #202124;
|
||||
--pokeball-white: #f7f8fb;
|
||||
--bg: #f2f5fa;
|
||||
@@ -540,6 +542,7 @@ button:disabled,
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea,
|
||||
.tags-select__search {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
@@ -555,12 +558,18 @@ button:disabled,
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus,
|
||||
.tags-select__search:focus {
|
||||
border-color: var(--pokemon-blue);
|
||||
box-shadow: 0 0 0 4px rgba(42, 117, 187, 0.16);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
min-height: 112px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -658,6 +667,13 @@ button:disabled,
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pokemon-measurement-fields,
|
||||
.pokemon-stats-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(138px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
justify-content: flex-end;
|
||||
@@ -1322,6 +1338,12 @@ button:disabled,
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pokemon-detail-sidebar {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.habitat-detail-stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@@ -1649,6 +1671,195 @@ button:disabled,
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pokemon-profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pokemon-profile-main,
|
||||
.pokemon-profile-row {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pokemon-profile-row {
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pokemon-profile-card,
|
||||
.pokemon-profile-stats {
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pokemon-profile-stats {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.pokemon-types-card {
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.pokemon-genus {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.pokemon-profile-divider {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.pokemon-measurement-display {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pokemon-measurement-item {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
min-width: 0;
|
||||
padding: 4px 18px;
|
||||
}
|
||||
|
||||
.pokemon-measurement-item + .pokemon-measurement-item {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pokemon-measurement-stack {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
gap: 7px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pokemon-measurement-value {
|
||||
color: var(--ink);
|
||||
font-size: 1.14rem;
|
||||
font-weight: 950;
|
||||
line-height: 1.05;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pokemon-measurement-divider {
|
||||
width: min(92px, 72%);
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.pokemon-measurement-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pokemon-stats-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pokemon-profile-facts {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pokemon-profile-facts div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.pokemon-profile-facts dt {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.pokemon-profile-facts dd {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pokemon-type-slots {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.pokemon-type-slots--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.pokemon-type-slot {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.progress-label span {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.progress-label span:last-child {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.appearance-list li {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(140px, 20%, 220px) minmax(0, 1fr);
|
||||
@@ -2086,6 +2297,8 @@ button:disabled,
|
||||
|
||||
.detail-grid,
|
||||
.detail-with-sidebar,
|
||||
.pokemon-profile-grid,
|
||||
.pokemon-profile-row,
|
||||
.admin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
|
||||
]);
|
||||
|
||||
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
|
||||
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
|
||||
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
||||
{ key: 'environments', label: t('config.environments') },
|
||||
{ key: 'favorite-things', label: t('config.favoriteThings') },
|
||||
|
||||
@@ -7,6 +7,7 @@ import DetailSection from '../components/DetailSection.vue';
|
||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit } from '../icons';
|
||||
@@ -122,6 +123,24 @@ const favoriteThingItems = computed(() => {
|
||||
|
||||
return items.filter((item) => String(item.category.id) === itemCategoryTab.value);
|
||||
});
|
||||
const typeSlotClass = computed(() => ({
|
||||
'pokemon-type-slots--single': (pokemon.value?.types.length ?? 0) === 1
|
||||
}));
|
||||
|
||||
function formatMetricMeasure(value: number): string {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function formatPoundsMeasure(value: number): string {
|
||||
return (Math.round(value * 10) / 10).toFixed(1);
|
||||
}
|
||||
|
||||
function formatImperialHeight(inches: number): string {
|
||||
const totalInches = Math.round(inches);
|
||||
const feet = Math.floor(totalInches / 12);
|
||||
const remainingInches = totalInches - feet * 12;
|
||||
return `${feet}'${remainingInches}"`;
|
||||
}
|
||||
|
||||
async function loadPokemonDetail() {
|
||||
pokemon.value = await api.pokemonDetail(String(route.params.id));
|
||||
@@ -223,6 +242,51 @@ watch(
|
||||
|
||||
<div class="detail-with-sidebar">
|
||||
<div class="detail-grid detail-grid--stack">
|
||||
<div class="pokemon-profile-grid">
|
||||
<div class="pokemon-profile-main">
|
||||
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')">
|
||||
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p>
|
||||
<div v-if="pokemon.genus && pokemon.details.trim()" class="pokemon-profile-divider"></div>
|
||||
<p v-if="pokemon.details.trim()" class="detail-text">{{ pokemon.details }}</p>
|
||||
<p v-if="!pokemon.genus && !pokemon.details.trim()" class="meta-line">{{ t('common.none') }}</p>
|
||||
</section>
|
||||
|
||||
<div class="pokemon-profile-row">
|
||||
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.measurements')">
|
||||
<div class="pokemon-measurement-display">
|
||||
<div class="pokemon-measurement-item" :title="`${formatImperialHeight(pokemon.heightInches)} / ${formatMetricMeasure(pokemon.heightMeters)} m`">
|
||||
<div class="pokemon-measurement-stack">
|
||||
<strong class="pokemon-measurement-value">{{ formatImperialHeight(pokemon.heightInches) }}</strong>
|
||||
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
|
||||
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.heightMeters) }} m</strong>
|
||||
<span class="pokemon-measurement-label">{{ t('pages.pokemon.height') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pokemon-measurement-item" :title="`${formatPoundsMeasure(pokemon.weightPounds)} lbs / ${formatMetricMeasure(pokemon.weightKg)} kg`">
|
||||
<div class="pokemon-measurement-stack">
|
||||
<strong class="pokemon-measurement-value">{{ formatPoundsMeasure(pokemon.weightPounds) }} lbs</strong>
|
||||
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
|
||||
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.weightKg) }} kg</strong>
|
||||
<span class="pokemon-measurement-label">{{ t('pages.pokemon.weight') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section pokemon-profile-card pokemon-types-card" :aria-label="t('pages.pokemon.types')">
|
||||
<div v-if="pokemon.types.length" class="pokemon-type-slots" :class="typeSlotClass">
|
||||
<span v-for="type in pokemon.types.slice(0, 2)" :key="type.id" class="chip">{{ type.name }}</span>
|
||||
</div>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
|
||||
<PokemonStatsPanel :stats="pokemon.stats" />
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
||||
<DetailSection :title="t('pages.pokemon.skills')">
|
||||
<EntityChips :items="pokemon.skills" />
|
||||
</DetailSection>
|
||||
@@ -287,7 +351,9 @@ watch(
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
||||
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
|
||||
<aside class="pokemon-detail-sidebar">
|
||||
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4,12 +4,22 @@ import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PokemonStatsFields from '../components/PokemonStatsFields.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 Language, type NamedEntity, type Options, type PokemonPayload, type TranslationMap } from '../services/api';
|
||||
import {
|
||||
api,
|
||||
type ConfigType,
|
||||
type Language,
|
||||
type NamedEntity,
|
||||
type Options,
|
||||
type PokemonPayload,
|
||||
type PokemonStats,
|
||||
type TranslationMap
|
||||
} from '../services/api';
|
||||
|
||||
type SkillItemDropForm = {
|
||||
skillId: string;
|
||||
@@ -26,10 +36,30 @@ const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
const creatingSelect = ref('');
|
||||
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||
const weightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||
|
||||
function defaultPokemonStats(): PokemonStats {
|
||||
return {
|
||||
hp: 0,
|
||||
attack: 0,
|
||||
defense: 0,
|
||||
specialAttack: 0,
|
||||
specialDefense: 0,
|
||||
speed: 0
|
||||
};
|
||||
}
|
||||
|
||||
const pokemonForm = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
genus: '',
|
||||
details: '',
|
||||
heightInches: 0,
|
||||
weightPounds: 0,
|
||||
translations: {} as TranslationMap,
|
||||
typeIds: [] as string[],
|
||||
stats: defaultPokemonStats(),
|
||||
environmentId: '',
|
||||
skillIds: [] as string[],
|
||||
favoriteThingIds: [] as string[],
|
||||
@@ -47,11 +77,47 @@ const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` :
|
||||
const selectedSkillDropRows = computed(() =>
|
||||
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
|
||||
);
|
||||
const totalHeightInchesValue = computed(() => Math.round(pokemonForm.value.heightInches));
|
||||
const heightFeetValue = computed(() => Math.floor(totalHeightInchesValue.value / 12));
|
||||
const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFeetValue.value * 12);
|
||||
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));
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
}
|
||||
|
||||
function numericInputValue(event: Event): number {
|
||||
const value = event.target instanceof HTMLInputElement ? Number(event.target.value) : 0;
|
||||
return Number.isFinite(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function roundMeasurement(value: number, precision: number): number {
|
||||
const scale = 10 ** precision;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
|
||||
function updateHeightFeet(event: Event) {
|
||||
pokemonForm.value.heightInches = Math.round(numericInputValue(event) * 12 + heightInchesValue.value);
|
||||
}
|
||||
|
||||
function updateHeightInches(event: Event) {
|
||||
pokemonForm.value.heightInches = Math.round(heightFeetValue.value * 12 + numericInputValue(event));
|
||||
}
|
||||
|
||||
function updateHeightMeters(event: Event) {
|
||||
pokemonForm.value.heightInches = roundMeasurement(numericInputValue(event) / 0.0254, 2);
|
||||
}
|
||||
|
||||
function updateWeightPounds(event: Event) {
|
||||
pokemonForm.value.weightPounds = roundMeasurement(numericInputValue(event), 1);
|
||||
}
|
||||
|
||||
function updateWeightKg(event: Event) {
|
||||
pokemonForm.value.weightPounds = roundMeasurement(numericInputValue(event) / 0.45359237, 1);
|
||||
}
|
||||
|
||||
function errorText(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
@@ -113,7 +179,13 @@ async function loadEditor() {
|
||||
pokemonForm.value = {
|
||||
id: String(pokemon.id),
|
||||
name: pokemon.baseName ?? pokemon.name,
|
||||
genus: pokemon.baseGenus ?? pokemon.genus,
|
||||
details: pokemon.baseDetails ?? pokemon.details,
|
||||
heightInches: pokemon.heightInches,
|
||||
weightPounds: pokemon.weightPounds,
|
||||
translations: pokemon.translations ?? {},
|
||||
typeIds: pokemon.types.map((type) => String(type.id)),
|
||||
stats: pokemon.stats,
|
||||
environmentId: String(pokemon.environment.id),
|
||||
skillIds: pokemon.skills.map((skill) => String(skill.id)),
|
||||
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)),
|
||||
@@ -176,7 +248,13 @@ async function savePokemon() {
|
||||
const payload: PokemonPayload = {
|
||||
id: Number(isEditing.value ? routeId.value : pokemonForm.value.id),
|
||||
name: pokemonNameForSave(),
|
||||
genus: pokemonForm.value.genus,
|
||||
details: pokemonForm.value.details,
|
||||
heightInches: pokemonForm.value.heightInches,
|
||||
weightPounds: pokemonForm.value.weightPounds,
|
||||
translations: pokemonForm.value.translations,
|
||||
typeIds: toIds(pokemonForm.value.typeIds.slice(0, 2)),
|
||||
stats: pokemonForm.value.stats,
|
||||
environmentId: Number(pokemonForm.value.environmentId),
|
||||
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
|
||||
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
||||
@@ -220,6 +298,96 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||
required
|
||||
/>
|
||||
|
||||
<TranslationFields
|
||||
id-prefix="pokemon-genus"
|
||||
v-model:base-value="pokemonForm.genus"
|
||||
v-model:translations="pokemonForm.translations"
|
||||
field="genus"
|
||||
:label="t('pages.pokemon.genus')"
|
||||
:languages="languages"
|
||||
/>
|
||||
|
||||
<TranslationFields
|
||||
id-prefix="pokemon-details"
|
||||
v-model:base-value="pokemonForm.details"
|
||||
v-model:translations="pokemonForm.translations"
|
||||
field="details"
|
||||
:label="t('pages.pokemon.details')"
|
||||
:languages="languages"
|
||||
multiline
|
||||
:rows="5"
|
||||
/>
|
||||
|
||||
<div class="field">
|
||||
<span id="pokemon-height-label" class="field-label">{{ t('pages.pokemon.height') }}</span>
|
||||
<div class="segmented" aria-labelledby="pokemon-height-label">
|
||||
<button :class="{ active: heightUnit === 'imperial' }" type="button" @click="heightUnit = 'imperial'">
|
||||
{{ t('pages.pokemon.heightImperial') }}
|
||||
</button>
|
||||
<button :class="{ active: heightUnit === 'metric' }" type="button" @click="heightUnit = 'metric'">
|
||||
{{ t('pages.pokemon.heightMetric') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="heightUnit === 'imperial'" class="pokemon-measurement-fields">
|
||||
<div class="field">
|
||||
<label for="pokemon-height-feet">{{ t('pages.pokemon.feet') }}</label>
|
||||
<input id="pokemon-height-feet" :value="heightFeetValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightFeet" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pokemon-height-inches">{{ t('pages.pokemon.inches') }}</label>
|
||||
<input id="pokemon-height-inches" :value="heightInchesValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightInches" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="field">
|
||||
<label for="pokemon-height-meters">{{ t('pages.pokemon.meters') }}</label>
|
||||
<input id="pokemon-height-meters" :value="heightMetersValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateHeightMeters" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span id="pokemon-weight-label" class="field-label">{{ t('pages.pokemon.weight') }}</span>
|
||||
<div class="segmented" aria-labelledby="pokemon-weight-label">
|
||||
<button :class="{ active: weightUnit === 'imperial' }" type="button" @click="weightUnit = 'imperial'">
|
||||
{{ t('pages.pokemon.pounds') }}
|
||||
</button>
|
||||
<button :class="{ active: weightUnit === 'metric' }" type="button" @click="weightUnit = 'metric'">
|
||||
{{ t('pages.pokemon.kilograms') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="weightUnit === 'imperial'" class="field">
|
||||
<label for="pokemon-weight-pounds">{{ t('pages.pokemon.pounds') }}</label>
|
||||
<input id="pokemon-weight-pounds" :value="weightPoundsValue" min="0" step="0.1" type="number" inputmode="decimal" @input="updateWeightPounds" />
|
||||
</div>
|
||||
|
||||
<div v-else class="field">
|
||||
<label for="pokemon-weight-kg">{{ t('pages.pokemon.kilograms') }}</label>
|
||||
<input id="pokemon-weight-kg" :value="weightKgValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateWeightKg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pokemon-types">{{ t('pages.pokemon.types') }}</label>
|
||||
<TagsSelect
|
||||
id="pokemon-types"
|
||||
v-model="pokemonForm.typeIds"
|
||||
:options="options.pokemonTypes"
|
||||
:max="2"
|
||||
allow-create
|
||||
:creating="creatingSelect === 'pokemon-types'"
|
||||
:placeholder="t('pages.pokemon.searchTypes')"
|
||||
@create="createMultiOption('pokemon-types', 'pokemon-types', $event, pokemonForm.typeIds, 2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">{{ t('pages.pokemon.statsTitle') }}</span>
|
||||
<PokemonStatsFields id-prefix="pokemon-stats" v-model="pokemonForm.stats" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
|
||||
<TagsSelect
|
||||
|
||||
@@ -145,6 +145,7 @@ watch(query, loadPokemon);
|
||||
:to="`/pokemon/${item.id}`"
|
||||
>
|
||||
<EditMeta :entity="item" />
|
||||
<EntityChips v-if="item.types.length" :items="item.types" />
|
||||
<EntityChips :items="item.skills" />
|
||||
<EntityChips :items="item.favorite_things" />
|
||||
</EntityCard>
|
||||
|
||||
Reference in New Issue
Block a user