feat: add ancient artifacts and refactor item categories

Introduce Ancient Artifacts with full CRUD and image support
Migrate item categories and usages to system-defined lists
Add display_id to items and artifacts for custom sorting
This commit is contained in:
2026-05-04 08:28:56 +08:00
parent 5ccc25b248
commit 4238be7761
25 changed files with 1857 additions and 181 deletions

View File

@@ -6,6 +6,7 @@ import AppShell from './components/AppShell.vue';
import {
iconAction,
iconAdmin,
iconArtifact,
iconAutomation,
iconChecklist,
iconClothes,
@@ -49,6 +50,8 @@ const navItems = computed(() => {
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
{ label: t('nav.eventHabitats'), to: '/event-habitats', icon: iconEvent },
{ label: t('nav.items'), to: '/items', icon: iconItem },
{ label: t('nav.eventItems'), to: '/event-items', icon: iconEvent },
{ label: t('nav.ancientArtifacts'), to: '/ancient-artifacts', icon: iconArtifact },
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
{ label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },

View File

@@ -16,11 +16,13 @@ const changeLabelKeys: Record<string, string> = {
标题: 'pages.checklist.task',
'Pokemon ID': 'pages.pokemon.id',
'Pokopia ID': 'pages.pokemon.id',
'Display ID': 'pages.items.displayId',
'Event item': 'common.eventItem',
'Event Pokemon': 'pages.pokemon.eventItem',
'Event Habitat': 'pages.habitats.eventItem',
Genus: 'pages.pokemon.genus',
Details: 'pages.pokemon.details',
Description: 'pages.items.description',
介绍: 'pages.pokemon.details',
Image: 'pages.pokemon.image',
图片: 'pages.pokemon.image',

View File

@@ -3,6 +3,7 @@ export type AppIcon = string;
export const iconAdd: AppIcon = 'mdi:plus';
export const iconAdmin: AppIcon = 'mdi:tune-variant';
export const iconAction: AppIcon = 'mdi:gesture-tap-button';
export const iconArtifact: AppIcon = 'mdi:diamond-stone';
export const iconAutomation: AppIcon = 'mdi:factory';
export const iconBack: AppIcon = 'mdi:arrow-left';
export const iconCancel: AppIcon = 'mdi:close';

View File

@@ -6,6 +6,8 @@ import HabitatList from '../views/HabitatList.vue';
import HabitatDetail from '../views/HabitatDetail.vue';
import ItemsList from '../views/ItemsList.vue';
import ItemDetail from '../views/ItemDetail.vue';
import AncientArtifactList from '../views/AncientArtifactList.vue';
import AncientArtifactDetail from '../views/AncientArtifactDetail.vue';
import RecipeList from '../views/RecipeList.vue';
import RecipeDetail from '../views/RecipeDetail.vue';
import DailyChecklistView from '../views/DailyChecklistView.vue';
@@ -133,17 +135,42 @@ export const router = createRouter({
}
},
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail, meta: { seo: seo({ titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }) } },
{ path: '/items', name: 'item-list', component: ItemsList, meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) } },
{
path: '/items',
name: 'item-list',
component: ItemsList,
props: { eventOnly: false },
meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) }
},
{
path: '/items/new',
name: 'item-new',
component: ItemsList,
props: { eventOnly: false },
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({ titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true })
}
},
{
path: '/event-items',
name: 'event-item-list',
component: ItemsList,
props: { eventOnly: true },
meta: { seo: seo({ titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }) }
},
{
path: '/event-items/new',
name: 'event-item-new',
component: ItemsList,
props: { eventOnly: true },
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({ titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true })
}
},
{
path: '/items/:id/edit',
name: 'item-edit',
@@ -160,6 +187,48 @@ export const router = createRouter({
}
},
{ path: '/items/:id', name: 'item-detail', component: ItemDetail, meta: { seo: seo({ titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }) } },
{
path: '/ancient-artifacts',
name: 'ancient-artifact-list',
component: AncientArtifactList,
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
},
{
path: '/ancient-artifacts/new',
name: 'ancient-artifact-new',
component: AncientArtifactList,
meta: {
requiredPermission: 'ancient-artifacts.create',
editorModal: true,
seo: seo({
titleKey: 'pages.ancientArtifacts.newTitle',
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
canonicalPath: '/ancient-artifacts',
noindex: true
})
}
},
{
path: '/ancient-artifacts/:id/edit',
name: 'ancient-artifact-edit',
component: AncientArtifactDetail,
meta: {
requiredPermission: 'ancient-artifacts.update',
editorModal: true,
seo: seo({
titleKey: 'pages.ancientArtifacts.editKicker',
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
canonicalPath: (route) => `/ancient-artifacts/${String(route.params.id)}`,
noindex: true
})
}
},
{
path: '/ancient-artifacts/:id',
name: 'ancient-artifact-detail',
component: AncientArtifactDetail,
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
},
{ path: '/recipes', name: 'recipe-list', component: RecipeList, meta: { seo: seo({ titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }) } },
{
path: '/recipes/new',

View File

@@ -117,7 +117,7 @@ export interface EntityImageUpload extends EntityImage {
uploadedBy: UserSummary | null;
}
export type ImageUploadEntityType = 'pokemon' | 'items' | 'habitats';
export type ImageUploadEntityType = 'pokemon' | 'items' | 'habitats' | 'ancient-artifacts';
export interface PokemonImage extends EntityImage {
style: string;
@@ -246,6 +246,7 @@ export interface HabitatUsage {
}
export interface RecipeResultItem extends NamedEntity {
displayId: number;
image?: EntityImage | null;
category?: NamedEntity;
usage?: NamedEntity | null;
@@ -253,8 +254,11 @@ export interface RecipeResultItem extends NamedEntity {
export interface Item extends EditInfo {
id: number;
displayId: number;
name: string;
baseName?: string;
details: string;
baseDetails?: string;
isEventItem: boolean;
translations?: TranslationMap;
image: EntityImage | null;
@@ -270,6 +274,24 @@ export interface Item extends EditInfo {
recipe: RecipeSummary | null;
}
export interface AncientArtifact extends EditInfo {
id: number;
displayId: number;
name: string;
baseName?: string;
details: string;
baseDetails?: string;
translations?: TranslationMap;
category: NamedEntity;
tags: NamedEntity[];
image: EntityImage | null;
}
export interface AncientArtifactDetail extends AncientArtifact {
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
}
export interface ItemDetail extends Item {
acquisitionMethods: NamedEntity[];
recipe: RecipeDetail | null;
@@ -296,7 +318,7 @@ export interface DailyChecklistItem {
translations?: TranslationMap;
}
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
export interface DataToolScopeSummary {
scope: DataToolScope;
@@ -395,6 +417,7 @@ export interface Options {
favoriteThings: NamedEntity[];
itemCategories: NamedEntity[];
itemUsages: NamedEntity[];
ancientArtifactCategories: NamedEntity[];
acquisitionMethods: NamedEntity[];
itemTags: NamedEntity[];
maps: NamedEntity[];
@@ -546,8 +569,6 @@ export type ConfigType =
| 'skills'
| 'environments'
| 'favorite-things'
| 'item-categories'
| 'item-usages'
| 'acquisition-methods'
| 'maps'
| 'life-tags'
@@ -598,7 +619,9 @@ export interface PokemonImageOptionsResult {
}
export interface ItemPayload {
displayId: number;
name: string;
details: string;
translations?: TranslationMap;
categoryId: number;
usageId: number | null;
@@ -612,6 +635,16 @@ export interface ItemPayload {
imagePath: string;
}
export interface AncientArtifactPayload {
displayId: number;
name: string;
details: string;
translations?: TranslationMap;
categoryId: number;
tagIds: number[];
imagePath: string;
}
export interface RecipePayload {
itemId: number;
acquisitionMethodIds: number[];
@@ -650,7 +683,7 @@ export interface LifeCommentPayload {
languageCode?: string | null;
}
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
export interface EntityDiscussionComment {
id: number;
@@ -1104,13 +1137,23 @@ export const api = {
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`),
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
items: (params: Record<string, string | number | undefined>) =>
items: (params: Record<string, string | number | boolean | undefined>) =>
getJson<Item[]>(`/api/items${buildQuery(params)}`),
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`),
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
ancientArtifacts: (params: Record<string, string | number | undefined> = {}) =>
getJson<AncientArtifact[]>(`/api/ancient-artifacts${buildQuery(params)}`),
ancientArtifactDetail: (id: string | number) => getJson<AncientArtifactDetail>(`/api/ancient-artifacts/${id}`),
createAncientArtifact: (payload: AncientArtifactPayload) =>
sendJson<AncientArtifactDetail>('/api/ancient-artifacts', 'POST', payload),
updateAncientArtifact: (id: string | number, payload: AncientArtifactPayload) =>
sendJson<AncientArtifactDetail>(`/api/ancient-artifacts/${id}`, 'PUT', payload),
deleteAncientArtifact: (id: string | number) => deleteJson(`/api/ancient-artifacts/${id}`),
reorderAncientArtifacts: (ids: number[]) =>
sendJson<AncientArtifact[]>('/api/admin/ancient-artifacts/order', 'PUT', { ids }),
recipes: (params: Record<string, string | number | undefined> = {}) =>
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),

View File

@@ -4114,6 +4114,13 @@ button:disabled,
min-width: 0;
}
.preserve-lines {
margin: 0;
max-width: 72ch;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.entity-profile-facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));

View File

@@ -12,6 +12,7 @@ import TranslationFields from '../components/TranslationFields.vue';
import {
iconAdd,
iconAdmin,
iconArtifact,
iconCancel,
iconChecklist,
iconDelete,
@@ -30,6 +31,7 @@ import {
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
import {
api,
type AncientArtifact,
type AiModerationApiFormat,
type AiModerationAuthMode,
type AiModerationSettings,
@@ -76,6 +78,7 @@ type AdminTab =
| 'checklist'
| 'pokemon'
| 'items'
| 'ancientArtifacts'
| 'recipes'
| 'habitats';
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
@@ -102,7 +105,7 @@ const rateLimitPolicyKeys: RateLimitPolicyKey[] = [
'upload',
'fetch'
];
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'];
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'];
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
@@ -126,6 +129,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
checklist: iconChecklist,
pokemon: iconPokemon,
items: iconItem,
ancientArtifacts: iconArtifact,
recipes: iconRecipe,
habitats: iconHabitat
};
@@ -146,6 +150,11 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
{ key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] },
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] },
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
{
key: 'ancientArtifacts',
label: t('pages.admin.ancientArtifactList'),
permission: ['ancient-artifacts.order', 'ancient-artifacts.delete']
},
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
@@ -185,8 +194,6 @@ const configTypes = computed<
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
{ key: 'environments', label: t('config.environments') },
{ key: 'favorite-things', label: t('config.favoriteThings') },
{ key: 'item-categories', label: t('config.itemCategories') },
{ key: 'item-usages', label: t('config.itemUsages') },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') },
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
@@ -203,6 +210,7 @@ const languageRows = ref<Language[]>([]);
const checklistRows = ref<DailyChecklistItem[]>([]);
const pokemonRows = ref<Pokemon[]>([]);
const itemRows = ref<Item[]>([]);
const ancientArtifactRows = ref<AncientArtifact[]>([]);
const recipeRows = ref<Recipe[]>([]);
const habitatRows = ref<Habitat[]>([]);
const wordingRows = ref<SystemWording[]>([]);
@@ -401,7 +409,9 @@ const configLabel = (item: EditableConfig) => item.name;
const pokemonKey = (item: Pokemon) => item.id;
const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
const itemKey = (item: Item) => item.id;
const itemLabel = (item: Item) => item.name;
const itemLabel = (item: Item) => `#${item.displayId} ${item.name}`;
const ancientArtifactKey = (item: AncientArtifact) => item.id;
const ancientArtifactLabel = (item: AncientArtifact) => `#${item.displayId} ${item.name}`;
const recipeKey = (item: Recipe) => item.id;
const recipeLabel = (item: Recipe) => item.name;
const habitatKey = (item: Habitat) => item.id;
@@ -768,6 +778,10 @@ function previewItemOrder(rows: Item[]) {
itemRows.value = rows;
}
function previewAncientArtifactOrder(rows: AncientArtifact[]) {
ancientArtifactRows.value = rows;
}
function previewRecipeOrder(rows: Recipe[]) {
recipeRows.value = rows;
}
@@ -837,6 +851,18 @@ async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
});
}
async function persistAncientArtifactOrder(nextRows: AncientArtifact[], fallbackRows: AncientArtifact[]) {
ancientArtifactRows.value = nextRows;
await run(async () => {
try {
ancientArtifactRows.value = await api.reorderAncientArtifacts(nextRows.map((item) => item.id));
} catch (error) {
ancientArtifactRows.value = fallbackRows;
throw error;
}
});
}
async function persistRecipeOrder(nextRows: Recipe[], fallbackRows: Recipe[]) {
recipeRows.value = nextRows;
await run(async () => {
@@ -944,6 +970,10 @@ async function loadItems() {
itemRows.value = await api.items({});
}
async function loadAncientArtifacts() {
ancientArtifactRows.value = await api.ancientArtifacts();
}
async function loadRecipes() {
recipeRows.value = await api.recipes();
}
@@ -1121,6 +1151,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'checklist') await loadChecklist();
if (activeTab.value === 'pokemon') await loadPokemon();
if (activeTab.value === 'items') await loadItems();
if (activeTab.value === 'ancientArtifacts') await loadAncientArtifacts();
if (activeTab.value === 'recipes') await loadRecipes();
if (activeTab.value === 'habitats') await loadHabitats();
} finally {
@@ -1208,6 +1239,13 @@ async function removeItem(id: number) {
});
}
async function removeAncientArtifact(id: number) {
await run(async () => {
await api.deleteAncientArtifact(id);
await loadAncientArtifacts();
});
}
async function removeRecipe(id: number) {
await run(async () => {
await api.deleteRecipe(id);
@@ -1982,7 +2020,7 @@ onMounted(() => {
@reorder="persistItemOrder"
>
<template #default="{ item }">
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
<RouterLink :to="`/items/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
<span class="row-actions">
<button v-if="can('items.delete')" type="button" :disabled="busy" @click="removeItem(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
@@ -1994,6 +2032,34 @@ onMounted(() => {
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'ancientArtifacts'" class="detail-section">
<h2>{{ t('pages.admin.ancientArtifactList') }}</h2>
<ReorderableList
v-if="ancientArtifactRows.length"
:items="ancientArtifactRows"
:item-key="ancientArtifactKey"
:item-label="ancientArtifactLabel"
list-key-prefix="ancient-artifacts"
:disabled="busy || !can('ancient-artifacts.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewAncientArtifactOrder"
@cancel="previewAncientArtifactOrder"
@reorder="persistAncientArtifactOrder"
>
<template #default="{ item }">
<RouterLink :to="`/ancient-artifacts/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
<span class="row-actions">
<button v-if="can('ancient-artifacts.delete')" type="button" :disabled="busy" @click="removeAncientArtifact(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</span>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
<h2>{{ t('pages.admin.recipeList') }}</h2>
<ReorderableList

View File

@@ -0,0 +1,170 @@
<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 EntityChips from '../components/EntityChips.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconArtifact, iconBack, iconEdit } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AncientArtifactDetail, type AuthUser } from '../services/api';
import AncientArtifactEdit from './AncientArtifactEdit.vue';
const route = useRoute();
const { t } = useI18n();
const artifact = ref<AncientArtifactDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
const showEditor = computed(() => route.name === 'ancient-artifact-edit');
const canUpdateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.update') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') }
]);
async function loadArtifactDetail() {
const nextArtifact = await api.ancientArtifactDetail(String(route.params.id));
artifact.value = nextArtifact;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextArtifact.name} - ${t('pages.ancientArtifacts.title')}`,
description: t('seo.ancientArtifactDetailDescription', { name: nextArtifact.name }),
canonicalPath: `/ancient-artifacts/${nextArtifact.id}`,
image: nextArtifact.image?.url
});
}
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadArtifactDetail();
});
watch(
() => route.name,
(name, oldName) => {
if (oldName === 'ancient-artifact-edit' && name === 'ancient-artifact-detail') {
void loadArtifactDetail();
}
}
);
watch(
() => route.params.id,
() => {
artifact.value = null;
detailTab.value = 'details';
void loadArtifactDetail();
}
);
</script>
<template>
<section v-if="!artifact" class="page-stack" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingDetail')">
<div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy">
<Skeleton width="132px" />
<Skeleton width="260px" height="46px" />
<Skeleton width="220px" />
</div>
<div class="page-header__actions">
<Skeleton variant="box" width="88px" height="36px" />
</div>
</div>
<section class="detail-section skeleton-detail-section" aria-hidden="true">
<div class="detail-section__header">
<Skeleton width="112px" height="24px" />
</div>
<div class="detail-section__body">
<Skeleton width="45%" />
<Skeleton variant="box" height="120px" />
</div>
</section>
</section>
<section v-else class="page-stack">
<PageHeader :title="`#${artifact.displayId} ${artifact.name}`" :subtitle="artifact.category.name">
<template #kicker>{{ t('pages.ancientArtifacts.detailKicker') }}</template>
<template #actions>
<RouterLink v-if="canUpdateArtifact" class="ui-button ui-button--primary ui-button--small" :to="`/ancient-artifacts/${artifact.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="/ancient-artifacts">
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
{{ t('common.backToList') }}
</RouterLink>
</template>
</PageHeader>
<div class="detail-tabs">
<Tabs id="artifact-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
<div v-if="detailTab === 'details'" class="detail-grid">
<DetailSection :title="t('common.details')">
<dl class="entity-profile-facts">
<div>
<dt>{{ t('pages.ancientArtifacts.displayId') }}</dt>
<dd>#{{ artifact.displayId }}</dd>
</div>
<div>
<dt>{{ t('pages.ancientArtifacts.category') }}</dt>
<dd>{{ artifact.category.name }}</dd>
</div>
</dl>
</DetailSection>
<DetailSection :title="t('media.image')">
<div class="entity-detail-image">
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !artifact.image }">
<img v-if="artifact.image" :src="artifact.image.url" :alt="t('media.imageAlt', { name: artifact.name })" />
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
<Icon :icon="iconArtifact" class="entity-card__icon" aria-hidden="true" />
</span>
</div>
</div>
</DetailSection>
<DetailSection :title="t('pages.ancientArtifacts.description')">
<p v-if="artifact.details" class="preserve-lines">{{ artifact.details }}</p>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
<DetailSection :title="t('pages.ancientArtifacts.category')">
<span class="chip">
<Icon :icon="iconArtifact" class="ui-icon" aria-hidden="true" />
{{ artifact.category.name }}
</span>
</DetailSection>
<DetailSection :title="t('pages.ancientArtifacts.tags')">
<EntityChips v-if="artifact.tags.length" :items="artifact.tags" />
<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="ancient-artifacts" :entity-id="artifact.id" />
</div>
<div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="artifact" :history="artifact.editHistory" />
</div>
</div>
</section>
<AncientArtifactEdit v-if="showEditor" />
</template>

View File

@@ -0,0 +1,269 @@
<script setup lang="ts">
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,
getAuthToken,
type AncientArtifactPayload,
type AuthUser,
type ConfigType,
type EntityImage,
type EntityImageUpload,
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 currentUser = ref<AuthUser | null>(null);
const currentImage = ref<EntityImage | null>(null);
const imageHistory = ref<EntityImageUpload[]>([]);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const artifactForm = ref({
displayId: 1,
name: '',
details: '',
translations: {} as TranslationMap,
categoryId: '',
tagIds: [] as string[],
imagePath: ''
});
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const pageTitle = computed(() =>
isEditing.value
? t('pages.ancientArtifacts.editTitle', { name: artifactForm.value.name || t('pages.ancientArtifacts.fallbackName') })
: t('pages.ancientArtifacts.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/ancient-artifacts/${routeId.value}` : '/ancient-artifacts'));
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.upload') === true);
const imageEntityName = computed(() => artifactNameForSave().trim());
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
function closeEditor() {
void router.push(cancelTo.value);
}
function artifactNameForSave() {
const baseName = artifactForm.value.name.trim();
if (baseName !== '') {
return artifactForm.value.name;
}
return artifactForm.value.translations[String(locale.value || '')]?.name ?? '';
}
async function loadEditor() {
loading.value = true;
message.value = '';
try {
const [loadedOptions, loadedLanguages] = await Promise.all([api.options(), api.languages()]);
options.value = loadedOptions;
languages.value = loadedLanguages;
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
if (isEditing.value) {
const artifact = await api.ancientArtifactDetail(routeId.value);
artifactForm.value = {
displayId: artifact.displayId,
name: artifact.baseName ?? artifact.name,
details: artifact.baseDetails ?? artifact.details,
translations: artifact.translations ?? {},
categoryId: String(artifact.category.id),
tagIds: artifact.tags.map((tag) => String(tag.id)),
imagePath: artifact.image?.path ?? ''
};
currentImage.value = artifact.image;
imageHistory.value = artifact.imageHistory;
}
} catch (error) {
message.value = errorText(error, t('errors.loadFailed'));
} finally {
loading.value = false;
}
}
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
try {
const created = await api.createConfig(type, { name: cleanName });
options.value = await api.options();
const value = String(created.id);
if (!values.includes(value)) {
values.push(value);
}
} catch (error) {
message.value = errorText(error, t('errors.addFailed'));
} finally {
creatingSelect.value = '';
}
}
async function saveArtifact() {
busy.value = true;
message.value = '';
try {
const payload: AncientArtifactPayload = {
displayId: artifactForm.value.displayId,
name: artifactNameForSave(),
details: artifactForm.value.details,
translations: artifactForm.value.translations,
categoryId: Number(artifactForm.value.categoryId),
tagIds: toIds(artifactForm.value.tagIds),
imagePath: artifactForm.value.imagePath
};
const saved = isEditing.value
? await api.updateAncientArtifact(routeId.value, payload)
: await api.createAncientArtifact(payload);
await router.push(`/ancient-artifacts/${saved.id}`);
} catch (error) {
message.value = errorText(error, t('errors.saveFailed'));
} finally {
busy.value = false;
}
}
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();
});
</script>
<template>
<Modal :title="pageTitle" :subtitle="t('pages.ancientArtifacts.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" id="artifact-edit-form" class="modal-edit-form" @submit.prevent="saveArtifact">
<TranslationFields
id-prefix="artifact-name"
v-model:base-value="artifactForm.name"
v-model:translations="artifactForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
<div class="field">
<label for="artifact-display-id">{{ t('pages.ancientArtifacts.displayId') }}</label>
<input id="artifact-display-id" v-model.number="artifactForm.displayId" type="number" min="1" required />
</div>
<TranslationFields
id-prefix="artifact-details"
v-model:base-value="artifactForm.details"
v-model:translations="artifactForm.translations"
field="details"
:label="t('pages.ancientArtifacts.description')"
:languages="languages"
multiline
:rows="4"
/>
<div class="field">
<label for="artifact-category">{{ t('pages.ancientArtifacts.category') }}</label>
<TagsSelect
id="artifact-category"
v-model="artifactForm.categoryId"
:options="options.ancientArtifactCategories"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.ancientArtifacts.searchCategory')"
/>
</div>
<ImageUploadField
v-model="artifactForm.imagePath"
entity-type="ancient-artifacts"
:entity-id="isEditing ? routeId : null"
:entity-name="imageEntityName"
:label="t('media.image')"
:current-image="currentImage"
:history="imageHistory"
:disabled="busy"
:allow-upload="canUploadImage"
@selected="handleImageSelected"
@uploaded="handleImageUploaded"
@error="message = $event"
/>
<div class="field">
<label for="artifact-tags">{{ t('pages.ancientArtifacts.tags') }}</label>
<TagsSelect
id="artifact-tags"
v-model="artifactForm.tagIds"
:options="options.itemTags"
:allow-create="canCreateConfig"
:creating="creatingSelect === 'artifact-tags'"
:placeholder="t('pages.ancientArtifacts.searchTags')"
@create="createMultiOption('artifact-tags', 'favorite-things', $event, artifactForm.tagIds)"
/>
</div>
</form>
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingEdit')">
<Skeleton width="160px" />
<Skeleton variant="box" height="44px" />
<Skeleton width="140px" />
<Skeleton variant="box" height="120px" />
<Skeleton width="120px" />
<Skeleton variant="box" height="44px" />
</section>
<template #footer>
<button type="button" class="ui-button ui-button--ghost" @click="closeEditor">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
<button type="submit" form="artifact-edit-form" class="ui-button ui-button--primary" :disabled="busy || loading">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,139 @@
<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 EntityCard from '../components/EntityCard.vue';
import FilterPanel from '../components/FilterPanel.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconArtifact } from '../icons';
import { api, getAuthToken, type AncientArtifact, type AuthUser, type Options } from '../services/api';
import AncientArtifactEdit from './AncientArtifactEdit.vue';
const route = useRoute();
const { t } = useI18n();
const options = ref<Options | null>(null);
const artifacts = ref<AncientArtifact[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const search = ref('');
const categoryId = ref('');
const tagIds = ref<string[]>([]);
const categorySkeletonWidths = ['64px', '132px', '132px', '86px'];
const filterSkeletonWidths = ['52px', '36px'];
const skeletonCardCount = 6;
const categoryTabs = computed<TabOption[]>(() => [
{ value: '', label: t('common.all') },
...(options.value?.ancientArtifactCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
]);
const artifactQuery = computed(() => ({
search: search.value,
categoryId: categoryId.value,
tagIds: tagIds.value.join(',')
}));
const showEditor = computed(() => route.name === 'ancient-artifact-new');
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.create') === true);
function artifactCardImage(artifact: AncientArtifact) {
return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined;
}
async function loadArtifacts() {
loading.value = true;
artifacts.value = await api.ancientArtifacts(artifactQuery.value);
loading.value = false;
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
options.value = await api.options();
await loadArtifacts();
});
watch(artifactQuery, loadArtifacts);
</script>
<template>
<section class="page-stack">
<PageHeader :title="t('pages.ancientArtifacts.title')" :subtitle="t('pages.ancientArtifacts.subtitle')">
<template #kicker>{{ t('pages.ancientArtifacts.kicker') }}</template>
<template #actions>
<RouterLink v-if="canCreateArtifact" class="ui-button ui-button--primary ui-button--small" to="/ancient-artifacts/new">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>
</template>
</PageHeader>
<Tabs v-if="options" id="artifact-category" v-model="categoryId" :tabs="categoryTabs" :label="t('pages.ancientArtifacts.category')" />
<div v-else class="tabs tabs--component" aria-hidden="true">
<div class="tab-list tab-list--skeleton">
<Skeleton
v-for="width in categorySkeletonWidths"
:key="width"
variant="box"
:width="width"
height="42px"
class="skeleton-tab"
/>
</div>
</div>
<FilterPanel v-if="options">
<div class="field">
<label for="artifact-search">{{ t('common.search') }}</label>
<input id="artifact-search" v-model="search" type="search" :placeholder="t('common.name')" />
</div>
<div class="field">
<label for="artifact-tags">{{ t('pages.ancientArtifacts.tags') }}</label>
<TagsSelect
id="artifact-tags"
v-model="tagIds"
:options="options.itemTags"
:placeholder="t('pages.ancientArtifacts.searchTags')"
/>
</div>
</FilterPanel>
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
<div v-for="(width, index) in filterSkeletonWidths" :key="index" class="field">
<Skeleton :width="width" />
<Skeleton variant="box" height="44px" />
</div>
</FilterPanel>
<div v-if="loading" class="entity-grid catalog-card-grid" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingList')">
<article v-for="index in skeletonCardCount" :key="`artifact-skeleton-${index}`" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
<div class="entity-card__content">
<Skeleton width="128px" height="24px" />
<Skeleton width="92px" />
</div>
</article>
</div>
<div v-else class="entity-grid catalog-card-grid">
<EntityCard
v-for="artifact in artifacts"
:key="artifact.id"
:title="`#${artifact.displayId} ${artifact.name}`"
:subtitle="artifact.category.name"
:to="`/ancient-artifacts/${artifact.id}`"
:icon="iconArtifact"
:image="artifactCardImage(artifact)"
/>
</div>
<AncientArtifactEdit v-if="showEditor" />
</section>
</template>

View File

@@ -7,6 +7,7 @@ import Skeleton from '../components/Skeleton.vue';
import StatusBadge from '../components/StatusBadge.vue';
import {
iconAction,
iconArtifact,
iconAutomation,
iconChevronRight,
iconChecklist,
@@ -36,6 +37,8 @@ const primarySections = computed(() => [
{ key: 'habitats', to: '/habitats', icon: iconHabitat },
{ key: 'eventHabitats', to: '/event-habitats', icon: iconEvent },
{ key: 'items', to: '/items', icon: iconItem },
{ key: 'eventItems', to: '/event-items', icon: iconEvent },
{ key: 'ancientArtifacts', to: '/ancient-artifacts', icon: iconArtifact },
{ key: 'recipes', to: '/recipes', icon: iconRecipe }
]);

View File

@@ -36,6 +36,8 @@ const itemSubtitle = computed(() => {
return item.value.usage ? `${item.value.category.name} · ${item.value.usage.name}` : item.value.category.name;
});
const detailKicker = computed(() => (item.value?.isEventItem ? t('pages.eventItems.detailKicker') : t('pages.items.detailKicker')));
const listTarget = computed(() => (item.value?.isEventItem ? '/event-items' : '/items'));
const customization = computed(() => {
if (!item.value) {
@@ -55,7 +57,7 @@ async function loadItemDetail() {
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextItem.name} - ${t('pages.items.title')}`,
title: `${nextItem.name} - ${t(nextItem.isEventItem ? 'pages.eventItems.title' : 'pages.items.title')}`,
description: t('seo.itemDetailDescription', { name: nextItem.name }),
canonicalPath: `/items/${nextItem.id}`,
image: nextItem.image?.url
@@ -147,14 +149,14 @@ watch(
</div>
</section>
<section v-else class="page-stack">
<PageHeader :title="item.name" :subtitle="itemSubtitle">
<template #kicker>{{ t('pages.items.detailKicker') }}</template>
<PageHeader :title="`#${item.displayId} ${item.name}`" :subtitle="itemSubtitle">
<template #kicker>{{ detailKicker }}</template>
<template #actions>
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.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="/items">
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="listTarget">
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
{{ t('common.backToList') }}
</RouterLink>
@@ -180,6 +182,10 @@ watch(
<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.items.displayId') }}</dt>
<dd>#{{ item.displayId }}</dd>
</div>
<div>
<dt>{{ t('pages.items.category') }}</dt>
<dd>{{ item.category.name }}</dd>
@@ -195,6 +201,11 @@ watch(
</dl>
<div class="entity-profile-groups">
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.description') }}</h3>
<p v-if="item.details" class="preserve-lines">{{ item.details }}</p>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
<EntityChips v-if="item.acquisitionMethods.length" :items="item.acquisitionMethods" />

View File

@@ -36,7 +36,9 @@ const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const itemForm = ref({
displayId: 1,
name: '',
details: '',
translations: {} as TranslationMap,
categoryId: '',
usageId: '',
@@ -52,12 +54,15 @@ const itemForm = ref({
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const isEventCreate = computed(() => route.name === 'event-item-new');
const pageTitle = computed(() =>
isEditing.value
? t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
: t('pages.items.newTitle')
: isEventCreate.value
? t('pages.eventItems.newTitle')
: t('pages.items.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : isEventCreate.value ? '/event-items' : '/items'));
const hasRecipe = ref(false);
const imageEntityName = computed(() => itemNameForSave().trim());
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
@@ -112,7 +117,9 @@ async function loadEditor() {
if (isEditing.value) {
const item = await api.itemDetail(routeId.value);
itemForm.value = {
displayId: item.displayId,
name: item.baseName ?? item.name,
details: item.baseDetails ?? item.details,
translations: item.translations ?? {},
categoryId: String(item.category.id),
usageId: item.usage ? String(item.usage.id) : '',
@@ -128,6 +135,10 @@ async function loadEditor() {
currentImage.value = item.image;
imageHistory.value = item.imageHistory;
hasRecipe.value = item.recipe !== null;
} else if (isEventCreate.value) {
itemForm.value.isEventItem = true;
} else {
itemForm.value.isEventItem = false;
}
} catch (error) {
message.value = errorText(error, t('errors.loadFailed'));
@@ -136,23 +147,6 @@ async function loadEditor() {
}
}
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
const cleanName = name.trim();
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
try {
const created = await api.createConfig(type, { name: cleanName });
await loadOptions();
assign(String(created.id));
} catch (error) {
message.value = errorText(error, t('errors.addFailed'));
} finally {
creatingSelect.value = '';
}
}
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName || !canCreateConfig.value) return;
@@ -179,7 +173,9 @@ async function saveItem() {
try {
const payload: ItemPayload = {
displayId: itemForm.value.displayId,
name: itemNameForSave(),
details: itemForm.value.details,
translations: itemForm.value.translations,
categoryId: Number(itemForm.value.categoryId),
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
@@ -230,6 +226,22 @@ onMounted(() => {
required
/>
<div class="field">
<label for="item-display-id">{{ t('pages.items.displayId') }}</label>
<input id="item-display-id" v-model.number="itemForm.displayId" type="number" min="1" required />
</div>
<TranslationFields
id-prefix="item-details"
v-model:base-value="itemForm.details"
v-model:translations="itemForm.translations"
field="details"
:label="t('pages.items.description')"
:languages="languages"
multiline
:rows="4"
/>
<ImageUploadField
v-model="itemForm.imagePath"
entity-type="items"
@@ -252,11 +264,8 @@ onMounted(() => {
v-model="itemForm.categoryId"
:options="options.itemCategories"
:multiple="false"
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-category'"
:placeholder="t('common.select')"
:search-placeholder="t('pages.items.searchCategory')"
@create="createSingleOption('item-category', 'item-categories', $event, (value) => (itemForm.categoryId = value))"
/>
</div>
@@ -267,11 +276,8 @@ onMounted(() => {
v-model="itemForm.usageId"
:options="options.itemUsages"
:multiple="false"
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-usage'"
:placeholder="t('common.none')"
:search-placeholder="t('pages.items.searchUsage')"
@create="createSingleOption('item-usage', 'item-usages', $event, (value) => (itemForm.usageId = value))"
/>
</div>
@@ -280,7 +286,7 @@ onMounted(() => {
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
<label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
<label><input v-model="itemForm.isEventItem" type="checkbox" /> {{ t('pages.items.eventItem') }}</label>
<label><input v-model="itemForm.isEventItem" type="checkbox" :disabled="isEventCreate" /> {{ t('pages.items.eventItem') }}</label>
</div>
<div class="field">

View File

@@ -13,6 +13,10 @@ import { iconAdd, iconItem } from '../icons';
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const props = defineProps<{
eventOnly?: boolean;
}>();
const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n();
@@ -27,6 +31,10 @@ const tagIds = ref<string[]>([]);
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
const filterSkeletonWidths = ['52px', '48px', '48px'];
const skeletonCardCount = 6;
const pageTitle = computed(() => (props.eventOnly ? t('pages.eventItems.title') : t('pages.items.title')));
const pageSubtitle = computed(() => (props.eventOnly ? t('pages.eventItems.subtitle') : t('pages.items.subtitle')));
const pageKicker = computed(() => (props.eventOnly ? t('pages.eventItems.kicker') : t('pages.items.kicker')));
const createTarget = computed(() => (props.eventOnly ? '/event-items/new' : '/items/new'));
const categoryTabs = computed<TabOption[]>(() => [
{ value: '', label: t('common.all') },
@@ -37,9 +45,10 @@ const itemQuery = computed(() => ({
search: search.value,
categoryId: categoryId.value,
usageId: usageId.value,
tagIds: tagIds.value.join(',')
tagIds: tagIds.value.join(','),
isEventItem: props.eventOnly
}));
const showEditor = computed(() => route.name === 'item-new');
const showEditor = computed(() => route.name === 'item-new' || route.name === 'event-item-new');
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
function itemCardImage(item: Item) {
@@ -69,10 +78,10 @@ watch(itemQuery, loadItems);
<template>
<section class="page-stack">
<PageHeader :title="t('pages.items.title')" :subtitle="t('pages.items.subtitle')">
<template #kicker>Bag</template>
<PageHeader :title="pageTitle" :subtitle="pageSubtitle">
<template #kicker>{{ pageKicker }}</template>
<template #actions>
<RouterLink v-if="canCreateItem" class="ui-button ui-button--primary ui-button--small" to="/items/new">
<RouterLink v-if="canCreateItem" class="ui-button ui-button--primary ui-button--small" :to="createTarget">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>
@@ -136,7 +145,7 @@ watch(itemQuery, loadItems);
<EntityCard
v-for="item in items"
:key="item.id"
:title="item.name"
:title="`#${item.displayId} ${item.name}`"
:subtitle="item.category.name"
:to="`/items/${item.id}`"
:icon="iconItem"

View File

@@ -114,7 +114,7 @@ watch(
</div>
</section>
<section v-else class="page-stack">
<PageHeader :title="recipe.name" :subtitle="recipeSubtitle">
<PageHeader :title="`#${recipe.item.displayId} ${recipe.name}`" :subtitle="recipeSubtitle">
<template #kicker>{{ t('pages.recipes.detailKicker') }}</template>
<template #actions>
<RouterLink v-if="canUpdateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
@@ -145,7 +145,7 @@ watch(
<Icon :icon="iconRecipe" class="entity-card__icon" aria-hidden="true" />
</span>
</RouterLink>
<RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">{{ recipe.item.name }}</RouterLink>
<RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">#{{ recipe.item.displayId }} {{ recipe.item.name }}</RouterLink>
</div>
</section>

View File

@@ -155,7 +155,7 @@ watch(itemQuery, loadItems);
<EntityCard
v-for="item in items"
:key="item.id"
:title="item.name"
:title="`#${item.displayId} ${item.name}`"
:subtitle="item.category.name"
:to="recipeTarget(item)"
:icon="itemIcon(item)"

View File

@@ -38,7 +38,7 @@ import {
} from '../services/api';
type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account';
type PrimaryContributionFilter = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'daily-checklist';
type PrimaryContributionFilter = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats' | 'daily-checklist';
type ContributionFilter = 'all' | PrimaryContributionFilter | 'config';
type ReactionFilter = 'all' | LifeReactionType;
type CommentFilter = 'all' | ProfileCommentSource;
@@ -46,6 +46,7 @@ type CommentFilter = 'all' | ProfileCommentSource;
const primaryContributionFilters: PrimaryContributionFilter[] = [
'pokemon',
'items',
'ancient-artifacts',
'recipes',
'habitats',
'daily-checklist'
@@ -582,6 +583,7 @@ function contentTypeLabel(contentType: string): string {
const labels: Record<string, string> = {
pokemon: t('nav.pokemon'),
items: t('nav.items'),
'ancient-artifacts': t('nav.ancientArtifacts'),
recipes: t('nav.recipes'),
habitats: t('nav.habitats'),
'daily-checklist': t('nav.checklist'),
@@ -589,8 +591,6 @@ function contentTypeLabel(contentType: string): string {
skills: t('config.skills'),
environments: t('config.environments'),
'favorite-things': t('config.favoriteThings'),
'item-categories': t('config.itemCategories'),
'item-usages': t('config.itemUsages'),
'acquisition-methods': t('config.acquisitionMethods'),
maps: t('config.maps'),
'life-tags': t('config.lifeCategories')
@@ -603,7 +603,8 @@ function discussionTargetRoute(type: DiscussionEntityType, id: number): string {
pokemon: `/pokemon/${id}`,
items: `/items/${id}`,
recipes: `/recipes/${id}`,
habitats: `/habitats/${id}`
habitats: `/habitats/${id}`,
'ancient-artifacts': `/ancient-artifacts/${id}`
}[type];
}