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:
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
/>
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="theme-color" content="#6ccf32" />
|
||||
@@ -16,7 +16,7 @@
|
||||
<meta property="og:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
/>
|
||||
<meta property="og:url" content="%POKOPIA_SITE_URL%/pokemon" />
|
||||
<meta property="og:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
||||
@@ -25,7 +25,7 @@
|
||||
<meta name="twitter:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
/>
|
||||
<meta name="twitter:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
||||
<script>
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
170
frontend/src/views/AncientArtifactDetail.vue
Normal file
170
frontend/src/views/AncientArtifactDetail.vue
Normal 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>
|
||||
269
frontend/src/views/AncientArtifactEdit.vue
Normal file
269
frontend/src/views/AncientArtifactEdit.vue
Normal 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>
|
||||
139
frontend/src/views/AncientArtifactList.vue
Normal file
139
frontend/src/views/AncientArtifactList.vue
Normal 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>
|
||||
@@ -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 }
|
||||
]);
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,18 @@ import vue from '@vitejs/plugin-vue';
|
||||
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
const frontendPort = 20015;
|
||||
const sitemapPaths = ['/pokemon', '/event-pokemon', '/habitats', '/event-habitats', '/items', '/recipes', '/checklist', '/life'];
|
||||
const sitemapPaths = [
|
||||
'/pokemon',
|
||||
'/event-pokemon',
|
||||
'/habitats',
|
||||
'/event-habitats',
|
||||
'/items',
|
||||
'/event-items',
|
||||
'/ancient-artifacts',
|
||||
'/recipes',
|
||||
'/checklist',
|
||||
'/life'
|
||||
];
|
||||
const robotsDisallowPaths = [
|
||||
'/admin',
|
||||
'/login',
|
||||
@@ -18,7 +29,10 @@ const robotsDisallowPaths = [
|
||||
'/event-habitats/new',
|
||||
'/habitats/*/edit',
|
||||
'/items/new',
|
||||
'/event-items/new',
|
||||
'/items/*/edit',
|
||||
'/ancient-artifacts/new',
|
||||
'/ancient-artifacts/*/edit',
|
||||
'/recipes/new',
|
||||
'/recipes/*/edit',
|
||||
'/automation',
|
||||
|
||||
Reference in New Issue
Block a user