refactor(frontend): migrate from Vite to Nuxt SPA
Replace Vite and Vue Router with Nuxt framework Update Docker, build scripts, and env vars for Nuxt generate
This commit is contained in:
@@ -8,10 +8,10 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install
|
||||
COPY frontend ./frontend
|
||||
COPY system-wordings.ts ./system-wordings.ts
|
||||
|
||||
ARG VITE_API_BASE_URL=http://localhost:3001
|
||||
ARG VITE_SITE_URL=https://pokopiawiki.tootaio.com
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ENV VITE_SITE_URL=$VITE_SITE_URL
|
||||
ARG NUXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
||||
ARG NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||
ENV NUXT_PUBLIC_API_BASE_URL=$NUXT_PUBLIC_API_BASE_URL
|
||||
ENV NUXT_PUBLIC_SITE_URL=$NUXT_PUBLIC_SITE_URL
|
||||
RUN pnpm --filter @pokopia/frontend build
|
||||
|
||||
FROM node:22-alpine
|
||||
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
|
||||
ENV PORT=20015
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/frontend/dist ./dist
|
||||
COPY --from=build /app/frontend/.output ./.output
|
||||
COPY frontend/static-server.mjs ./static-server.mjs
|
||||
|
||||
USER node
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppShell from './components/AppShell.vue';
|
||||
import AppShell from './src/components/AppShell.vue';
|
||||
import {
|
||||
iconAction,
|
||||
iconAdmin,
|
||||
@@ -20,12 +19,11 @@ import {
|
||||
iconPokemon,
|
||||
iconRecipe,
|
||||
type AppIcon
|
||||
} from './icons';
|
||||
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './i18n';
|
||||
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
|
||||
} from './src/icons';
|
||||
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
|
||||
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './src/services/api';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const router = useRouter();
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const languages = ref<Language[]>([
|
||||
@@ -188,6 +186,6 @@ onUnmounted(() => {
|
||||
@logout="logout"
|
||||
@update:locale="updateLocale"
|
||||
>
|
||||
<RouterView :key="locale" />
|
||||
<NuxtPage :key="locale" />
|
||||
</AppShell>
|
||||
</template>
|
||||
9
frontend/app/router.options.ts
Normal file
9
frontend/app/router.options.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { RouterConfig } from '@nuxt/schema';
|
||||
|
||||
export default <RouterConfig>{
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) return savedPosition;
|
||||
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
|
||||
return { top: 0 };
|
||||
}
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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, 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" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="canonical" href="%POKOPIA_SITE_URL%/pokemon" />
|
||||
<meta property="og:site_name" content="Pokopia Wiki" />
|
||||
<meta property="og:type" content="website" />
|
||||
<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, 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" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<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, 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>
|
||||
(function () {
|
||||
const UMAMI_SCRIPT_JS = "https://umami.tootaio.com/script.js";
|
||||
const UMAMI_ID = "6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb";
|
||||
|
||||
var script = document.createElement("script");
|
||||
script.async = true;
|
||||
script.src = UMAMI_SCRIPT_JS;
|
||||
script.setAttribute("data-website-id", UMAMI_ID);
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<title>Pokopia Wiki - Pokemon Pokopia Guide</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
frontend/middleware/auth.global.ts
Normal file
40
frontend/middleware/auth.global.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { api, getAuthToken, setAuthToken } from '../src/services/api';
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const requiredPermissions = to.matched
|
||||
.map((record) => record.meta.requiredPermission)
|
||||
.filter((permission): permission is string => typeof permission === 'string');
|
||||
const requiredAnyPermissions = to.matched.flatMap((record) =>
|
||||
Array.isArray(record.meta.requiredAnyPermission)
|
||||
? record.meta.requiredAnyPermission.filter((permission): permission is string => typeof permission === 'string')
|
||||
: []
|
||||
);
|
||||
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true) || requiredPermissions.length > 0 || requiredAnyPermissions.length > 0;
|
||||
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
|
||||
|
||||
if (!requiresAuth) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getAuthToken()) {
|
||||
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
if (requiresVerified && !response.user.emailVerified) {
|
||||
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
||||
}
|
||||
|
||||
const permissionSet = new Set(response.user.permissions);
|
||||
if (requiredPermissions.some((permission) => !permissionSet.has(permission))) {
|
||||
return navigateTo('/pokemon');
|
||||
}
|
||||
if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) {
|
||||
return navigateTo('/pokemon');
|
||||
}
|
||||
} catch {
|
||||
setAuthToken(null);
|
||||
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
||||
}
|
||||
});
|
||||
70
frontend/nuxt.config.ts
Normal file
70
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
|
||||
function normalizeSiteUrl(value: string | undefined): string {
|
||||
return (value?.trim() || fallbackSiteUrl).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
devtools: { enabled: false },
|
||||
css: ['~/src/styles/main.css'],
|
||||
compatibilityDate: '2026-05-06',
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? process.env.VITE_API_BASE_URL ?? 'http://localhost:3001',
|
||||
siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)
|
||||
}
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: {
|
||||
lang: 'en'
|
||||
},
|
||||
title: 'Pokopia Wiki - Pokemon Pokopia Guide',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
|
||||
{
|
||||
name: 'description',
|
||||
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.'
|
||||
},
|
||||
{ name: 'robots', content: 'index, follow' },
|
||||
{ name: 'theme-color', content: '#6ccf32' },
|
||||
{ property: 'og:site_name', content: 'Pokopia Wiki' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:title', content: 'Pokopia Wiki - Pokemon Pokopia Guide' },
|
||||
{
|
||||
property: 'og:description',
|
||||
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.'
|
||||
},
|
||||
{ property: 'og:image', content: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/seo/pokopia-hero.jpg` },
|
||||
{ property: 'og:locale', content: 'en_US' },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
{ name: 'twitter:title', content: 'Pokopia Wiki - Pokemon Pokopia Guide' },
|
||||
{
|
||||
name: 'twitter:description',
|
||||
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.'
|
||||
},
|
||||
{ name: 'twitter:image', content: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/seo/pokopia-hero.jpg` }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' },
|
||||
{ rel: 'canonical', href: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/pokemon` }
|
||||
],
|
||||
script: [
|
||||
{
|
||||
innerHTML:
|
||||
'(function(){const s=document.createElement("script");s.async=true;s.src="https://umami.tootaio.com/script.js";s.setAttribute("data-website-id","6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb");document.head.appendChild(s);})();'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
nitro: {
|
||||
prerender: {
|
||||
routes: ['/robots.txt', '/sitemap.xml']
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -5,16 +5,15 @@
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 20015",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"lint": "vue-tsc --noEmit",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"dev": "nuxt dev --host 0.0.0.0 --port 20015",
|
||||
"build": "nuxt generate",
|
||||
"lint": "nuxt typecheck",
|
||||
"typecheck": "nuxt typecheck",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "5.0.0",
|
||||
"@vitejs/plugin-vue": "6.0.6",
|
||||
"vite": "8.0.10",
|
||||
"nuxt": "4.4.4",
|
||||
"vue": "3.5.33",
|
||||
"vue-i18n": "11.4.0",
|
||||
"vue-router": "5.0.6"
|
||||
@@ -22,6 +21,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "25.6.0",
|
||||
"@vue/tsconfig": "0.9.1",
|
||||
"postcss": "8.5.13",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5",
|
||||
"vue-tsc": "3.2.7"
|
||||
|
||||
12
frontend/pages/actions.vue
Normal file
12
frontend/pages/actions.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'actions',
|
||||
seo: { titleKey: 'pages.comingSoon.sections.actions.title', descriptionKey: 'pages.comingSoon.sections.actions.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoonView page="actions" />
|
||||
</template>
|
||||
13
frontend/pages/admin.vue
Normal file
13
frontend/pages/admin.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import AdminView from '../src/views/AdminView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'admin',
|
||||
requiredPermission: 'admin.access',
|
||||
seo: { titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminView />
|
||||
</template>
|
||||
20
frontend/pages/ancient-artifacts/[id]/edit.vue
Normal file
20
frontend/pages/ancient-artifacts/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'ancient-artifact-edit',
|
||||
requiredPermission: 'items.update',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.ancientArtifacts.editKicker',
|
||||
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/ancient-artifacts/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemDetail />
|
||||
</template>
|
||||
12
frontend/pages/ancient-artifacts/[id]/index.vue
Normal file
12
frontend/pages/ancient-artifacts/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'ancient-artifact-detail',
|
||||
seo: { titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemDetail />
|
||||
</template>
|
||||
12
frontend/pages/ancient-artifacts/index.vue
Normal file
12
frontend/pages/ancient-artifacts/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import AncientArtifactList from '../../src/views/AncientArtifactList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'ancient-artifact-list',
|
||||
seo: { titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AncientArtifactList />
|
||||
</template>
|
||||
19
frontend/pages/ancient-artifacts/new.vue
Normal file
19
frontend/pages/ancient-artifacts/new.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import AncientArtifactList from '../../src/views/AncientArtifactList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'ancient-artifact-new',
|
||||
requiredPermission: 'items.create',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.ancientArtifacts.newTitle',
|
||||
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||
canonicalPath: '/ancient-artifacts',
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AncientArtifactList />
|
||||
</template>
|
||||
12
frontend/pages/automation.vue
Normal file
12
frontend/pages/automation.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'automation',
|
||||
seo: { titleKey: 'pages.comingSoon.sections.automation.title', descriptionKey: 'pages.comingSoon.sections.automation.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoonView page="automation" />
|
||||
</template>
|
||||
12
frontend/pages/checklist.vue
Normal file
12
frontend/pages/checklist.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import DailyChecklistView from '../src/views/DailyChecklistView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'checklist',
|
||||
seo: { titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DailyChecklistView />
|
||||
</template>
|
||||
12
frontend/pages/clothes.vue
Normal file
12
frontend/pages/clothes.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'clothes',
|
||||
seo: { titleKey: 'pages.comingSoon.sections.clothes.title', descriptionKey: 'pages.comingSoon.sections.clothes.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoonView page="clothes" />
|
||||
</template>
|
||||
12
frontend/pages/disclaimers.vue
Normal file
12
frontend/pages/disclaimers.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LegalView from '../src/views/LegalView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'disclaimers',
|
||||
seo: { titleKey: 'pages.legal.disclaimers.title', descriptionKey: 'pages.legal.disclaimers.subtitle', canonicalPath: '/disclaimers' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LegalView page="disclaimers" />
|
||||
</template>
|
||||
12
frontend/pages/dish.vue
Normal file
12
frontend/pages/dish.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import DishView from '../src/views/DishView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'dish',
|
||||
seo: { titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DishView />
|
||||
</template>
|
||||
12
frontend/pages/dream-island.vue
Normal file
12
frontend/pages/dream-island.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'dream-island',
|
||||
seo: { titleKey: 'pages.comingSoon.sections.dreamIsland.title', descriptionKey: 'pages.comingSoon.sections.dreamIsland.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoonView page="dreamIsland" />
|
||||
</template>
|
||||
12
frontend/pages/event-habitats/index.vue
Normal file
12
frontend/pages/event-habitats/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import HabitatList from '../../src/views/HabitatList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-habitat-list',
|
||||
seo: { titleKey: 'pages.eventHabitats.title', descriptionKey: 'pages.eventHabitats.subtitle', canonicalPath: '/event-habitats' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatList :event-only="true" />
|
||||
</template>
|
||||
14
frontend/pages/event-habitats/new.vue
Normal file
14
frontend/pages/event-habitats/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import HabitatList from '../../src/views/HabitatList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-habitat-new',
|
||||
requiredPermission: 'habitats.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.eventHabitats.newTitle', descriptionKey: 'pages.eventHabitats.editSubtitle', canonicalPath: '/event-habitats', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatList :event-only="true" />
|
||||
</template>
|
||||
12
frontend/pages/event-items/index.vue
Normal file
12
frontend/pages/event-items/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ItemsList from '../../src/views/ItemsList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-item-list',
|
||||
seo: { titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemsList :event-only="true" />
|
||||
</template>
|
||||
14
frontend/pages/event-items/new.vue
Normal file
14
frontend/pages/event-items/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import ItemsList from '../../src/views/ItemsList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-item-new',
|
||||
requiredPermission: 'items.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemsList :event-only="true" />
|
||||
</template>
|
||||
12
frontend/pages/event-pokemon/index.vue
Normal file
12
frontend/pages/event-pokemon/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import PokemonList from '../../src/views/PokemonList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-pokemon-list',
|
||||
seo: { titleKey: 'pages.eventPokemon.title', descriptionKey: 'pages.eventPokemon.subtitle', canonicalPath: '/event-pokemon' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonList :event-only="true" />
|
||||
</template>
|
||||
14
frontend/pages/event-pokemon/new.vue
Normal file
14
frontend/pages/event-pokemon/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import PokemonList from '../../src/views/PokemonList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-pokemon-new',
|
||||
requiredPermission: 'pokemon.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.eventPokemon.newTitle', descriptionKey: 'pages.eventPokemon.editSubtitle', canonicalPath: '/event-pokemon', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonList :event-only="true" />
|
||||
</template>
|
||||
12
frontend/pages/events.vue
Normal file
12
frontend/pages/events.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'events',
|
||||
seo: { titleKey: 'pages.comingSoon.sections.events.title', descriptionKey: 'pages.comingSoon.sections.events.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoonView page="events" />
|
||||
</template>
|
||||
12
frontend/pages/forgot-password.vue
Normal file
12
frontend/pages/forgot-password.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ForgotPasswordView from '../src/views/ForgotPasswordView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'forgot-password',
|
||||
seo: { titleKey: 'auth.requestResetTitle', descriptionKey: 'auth.requestResetSubtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ForgotPasswordView />
|
||||
</template>
|
||||
20
frontend/pages/habitats/[id]/edit.vue
Normal file
20
frontend/pages/habitats/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import HabitatDetail from '../../../src/views/HabitatDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'habitat-edit',
|
||||
requiredPermission: 'habitats.update',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.habitats.detailKicker',
|
||||
descriptionKey: 'pages.habitats.editSubtitle',
|
||||
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/habitats/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatDetail />
|
||||
</template>
|
||||
12
frontend/pages/habitats/[id]/index.vue
Normal file
12
frontend/pages/habitats/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import HabitatDetail from '../../../src/views/HabitatDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'habitat-detail',
|
||||
seo: { titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatDetail />
|
||||
</template>
|
||||
12
frontend/pages/habitats/index.vue
Normal file
12
frontend/pages/habitats/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import HabitatList from '../../src/views/HabitatList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'habitat-list',
|
||||
seo: { titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatList :event-only="false" />
|
||||
</template>
|
||||
14
frontend/pages/habitats/new.vue
Normal file
14
frontend/pages/habitats/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import HabitatList from '../../src/views/HabitatList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'habitat-new',
|
||||
requiredPermission: 'habitats.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatList :event-only="false" />
|
||||
</template>
|
||||
12
frontend/pages/index.vue
Normal file
12
frontend/pages/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import HomeView from '../src/views/HomeView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'home',
|
||||
seo: { titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HomeView />
|
||||
</template>
|
||||
20
frontend/pages/items/[id]/edit.vue
Normal file
20
frontend/pages/items/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'item-edit',
|
||||
requiredPermission: 'items.update',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.items.editKicker',
|
||||
descriptionKey: 'pages.items.editSubtitle',
|
||||
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/items/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemDetail />
|
||||
</template>
|
||||
12
frontend/pages/items/[id]/index.vue
Normal file
12
frontend/pages/items/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'item-detail',
|
||||
seo: { titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemDetail />
|
||||
</template>
|
||||
12
frontend/pages/items/index.vue
Normal file
12
frontend/pages/items/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ItemsList from '../../src/views/ItemsList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'item-list',
|
||||
seo: { titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemsList :event-only="false" />
|
||||
</template>
|
||||
14
frontend/pages/items/new.vue
Normal file
14
frontend/pages/items/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import ItemsList from '../../src/views/ItemsList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'item-new',
|
||||
requiredPermission: 'items.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemsList :event-only="false" />
|
||||
</template>
|
||||
12
frontend/pages/life/[id].vue
Normal file
12
frontend/pages/life/[id].vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LifePostDetail from '../../src/views/LifePostDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'life-id',
|
||||
seo: { titleKey: 'pages.life.detailTitle', descriptionKey: 'pages.life.detailSubtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LifePostDetail />
|
||||
</template>
|
||||
12
frontend/pages/life/index.vue
Normal file
12
frontend/pages/life/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LifeView from '../../src/views/LifeView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'life',
|
||||
seo: { titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LifeView />
|
||||
</template>
|
||||
12
frontend/pages/login.vue
Normal file
12
frontend/pages/login.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LoginView from '../src/views/LoginView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'login',
|
||||
seo: { titleKey: 'auth.loginTitle', descriptionKey: 'auth.loginSubtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginView />
|
||||
</template>
|
||||
20
frontend/pages/pokemon/[id]/edit.vue
Normal file
20
frontend/pages/pokemon/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import PokemonDetail from '../../../src/views/PokemonDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'pokemon-edit',
|
||||
requiredPermission: 'pokemon.update',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.pokemon.editKicker',
|
||||
descriptionKey: 'pages.pokemon.editSubtitle',
|
||||
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/pokemon/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonDetail />
|
||||
</template>
|
||||
12
frontend/pages/pokemon/[id]/index.vue
Normal file
12
frontend/pages/pokemon/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import PokemonDetail from '../../../src/views/PokemonDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'pokemon-detail',
|
||||
seo: { titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonDetail />
|
||||
</template>
|
||||
12
frontend/pages/pokemon/index.vue
Normal file
12
frontend/pages/pokemon/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import PokemonList from '../../src/views/PokemonList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'pokemon-list',
|
||||
seo: { titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonList :event-only="false" />
|
||||
</template>
|
||||
14
frontend/pages/pokemon/new.vue
Normal file
14
frontend/pages/pokemon/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import PokemonList from '../../src/views/PokemonList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'pokemon-new',
|
||||
requiredPermission: 'pokemon.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonList :event-only="false" />
|
||||
</template>
|
||||
12
frontend/pages/privacy-policy.vue
Normal file
12
frontend/pages/privacy-policy.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LegalView from '../src/views/LegalView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'privacy-policy',
|
||||
seo: { titleKey: 'pages.legal.privacy.title', descriptionKey: 'pages.legal.privacy.subtitle', canonicalPath: '/privacy-policy' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LegalView page="privacy" />
|
||||
</template>
|
||||
12
frontend/pages/profile/[id].vue
Normal file
12
frontend/pages/profile/[id].vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import UserProfileView from '../../src/views/UserProfileView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'profile-id',
|
||||
seo: { titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserProfileView />
|
||||
</template>
|
||||
13
frontend/pages/profile/index.vue
Normal file
13
frontend/pages/profile/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import UserProfileView from '../../src/views/UserProfileView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'profile',
|
||||
requiresAuth: true,
|
||||
seo: { titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserProfileView />
|
||||
</template>
|
||||
16
frontend/pages/project-updates.vue
Normal file
16
frontend/pages/project-updates.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import ProjectUpdatesView from '../src/views/ProjectUpdatesView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'project-updates',
|
||||
seo: {
|
||||
titleKey: 'pages.projectUpdates.title',
|
||||
descriptionKey: 'pages.projectUpdates.subtitle',
|
||||
canonicalPath: '/project-updates'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProjectUpdatesView />
|
||||
</template>
|
||||
20
frontend/pages/recipes/[id]/edit.vue
Normal file
20
frontend/pages/recipes/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import RecipeDetail from '../../../src/views/RecipeDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'recipe-edit',
|
||||
requiredPermission: 'recipes.update',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.recipes.editKicker',
|
||||
descriptionKey: 'pages.recipes.editSubtitle',
|
||||
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/recipes/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeDetail />
|
||||
</template>
|
||||
12
frontend/pages/recipes/[id]/index.vue
Normal file
12
frontend/pages/recipes/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import RecipeDetail from '../../../src/views/RecipeDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'recipe-detail',
|
||||
seo: { titleKey: 'pages.recipes.detailKicker', descriptionKey: 'pages.recipes.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeDetail />
|
||||
</template>
|
||||
12
frontend/pages/recipes/index.vue
Normal file
12
frontend/pages/recipes/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import RecipeList from '../../src/views/RecipeList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'recipe-list',
|
||||
seo: { titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeList />
|
||||
</template>
|
||||
14
frontend/pages/recipes/new.vue
Normal file
14
frontend/pages/recipes/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import RecipeList from '../../src/views/RecipeList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'recipe-new',
|
||||
requiredPermission: 'recipes.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.recipes.newTitle', descriptionKey: 'pages.recipes.editSubtitle', canonicalPath: '/recipes', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeList />
|
||||
</template>
|
||||
12
frontend/pages/register.vue
Normal file
12
frontend/pages/register.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import RegisterView from '../src/views/RegisterView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'register',
|
||||
seo: { titleKey: 'auth.registerTitle', descriptionKey: 'auth.registerSubtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RegisterView />
|
||||
</template>
|
||||
12
frontend/pages/reset-password.vue
Normal file
12
frontend/pages/reset-password.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ResetPasswordView from '../src/views/ResetPasswordView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'reset-password',
|
||||
seo: { titleKey: 'auth.resetTitle', descriptionKey: 'auth.resetSubtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ResetPasswordView />
|
||||
</template>
|
||||
12
frontend/pages/terms-of-service.vue
Normal file
12
frontend/pages/terms-of-service.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LegalView from '../src/views/LegalView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'terms-of-service',
|
||||
seo: { titleKey: 'pages.legal.terms.title', descriptionKey: 'pages.legal.terms.subtitle', canonicalPath: '/terms-of-service' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LegalView page="terms" />
|
||||
</template>
|
||||
12
frontend/pages/verify-email.vue
Normal file
12
frontend/pages/verify-email.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import VerifyEmailView from '../src/views/VerifyEmailView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'verify-email',
|
||||
seo: { titleKey: 'auth.verifyTitle', descriptionKey: 'auth.verifySubtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VerifyEmailView />
|
||||
</template>
|
||||
10
frontend/plugins/00-runtime-config.ts
Normal file
10
frontend/plugins/00-runtime-config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { setSystemWordingsApiBaseUrl } from '../src/i18n';
|
||||
import { setConfiguredSiteUrl } from '../src/seo';
|
||||
import { setApiBaseUrl } from '../src/services/api';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const config = useRuntimeConfig();
|
||||
setApiBaseUrl(config.public.apiBaseUrl);
|
||||
setSystemWordingsApiBaseUrl(config.public.apiBaseUrl);
|
||||
setConfiguredSiteUrl(config.public.siteUrl);
|
||||
});
|
||||
5
frontend/plugins/01-i18n.ts
Normal file
5
frontend/plugins/01-i18n.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { i18n } from '../src/i18n';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.use(i18n);
|
||||
});
|
||||
14
frontend/plugins/02-seo.client.ts
Normal file
14
frontend/plugins/02-seo.client.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { onLocaleChange } from '../src/i18n';
|
||||
import { applyRouteSeo } from '../src/seo';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const router = useRouter();
|
||||
|
||||
router.afterEach((to) => {
|
||||
applyRouteSeo(to);
|
||||
});
|
||||
|
||||
onLocaleChange(() => {
|
||||
applyRouteSeo(router.currentRoute.value);
|
||||
});
|
||||
});
|
||||
7
frontend/server/routes/robots.txt.ts
Normal file
7
frontend/server/routes/robots.txt.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { normalizeSiteUrl, robotsTxt } from '../utils/seo-files';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig(event);
|
||||
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8');
|
||||
return robotsTxt(normalizeSiteUrl(config.public.siteUrl));
|
||||
});
|
||||
7
frontend/server/routes/sitemap.xml.ts
Normal file
7
frontend/server/routes/sitemap.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { normalizeSiteUrl, sitemapXml } from '../utils/seo-files';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig(event);
|
||||
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||
return sitemapXml(normalizeSiteUrl(config.public.siteUrl));
|
||||
});
|
||||
68
frontend/server/utils/seo-files.ts
Normal file
68
frontend/server/utils/seo-files.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
|
||||
const sitemapPaths = [
|
||||
'/pokemon',
|
||||
'/event-pokemon',
|
||||
'/habitats',
|
||||
'/event-habitats',
|
||||
'/items',
|
||||
'/event-items',
|
||||
'/ancient-artifacts',
|
||||
'/recipes',
|
||||
'/dish',
|
||||
'/checklist',
|
||||
'/life'
|
||||
];
|
||||
|
||||
const robotsDisallowPaths = [
|
||||
'/admin',
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/verify-email',
|
||||
'/pokemon/new',
|
||||
'/event-pokemon/new',
|
||||
'/pokemon/*/edit',
|
||||
'/habitats/new',
|
||||
'/event-habitats/new',
|
||||
'/habitats/*/edit',
|
||||
'/items/new',
|
||||
'/event-items/new',
|
||||
'/items/*/edit',
|
||||
'/ancient-artifacts/new',
|
||||
'/ancient-artifacts/*/edit',
|
||||
'/recipes/new',
|
||||
'/recipes/*/edit',
|
||||
'/automation',
|
||||
'/events',
|
||||
'/actions',
|
||||
'/dream-island',
|
||||
'/clothes'
|
||||
];
|
||||
|
||||
export function normalizeSiteUrl(value: unknown): string {
|
||||
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackSiteUrl).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function robotsTxt(siteUrl: string): string {
|
||||
const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n');
|
||||
return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`;
|
||||
}
|
||||
|
||||
export function sitemapXml(siteUrl: string): string {
|
||||
const urls = sitemapPaths
|
||||
.map(
|
||||
(path) => ` <url>
|
||||
<loc>${siteUrl}${path}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
</url>`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls}
|
||||
</urlset>
|
||||
`;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { createI18n } from 'vue-i18n';
|
||||
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
|
||||
|
||||
export { defaultLocale } from '../../system-wordings';
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
||||
let apiBaseUrl = 'http://localhost:3001';
|
||||
const localeStorageKey = 'pokopia_locale';
|
||||
const localeChangeEvent = 'pokopia-locale-change';
|
||||
|
||||
@@ -25,6 +25,12 @@ export const i18n = createI18n({
|
||||
messages
|
||||
});
|
||||
|
||||
export function setSystemWordingsApiBaseUrl(value: unknown): void {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
apiBaseUrl = value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredLocale(): string {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return defaultLocale;
|
||||
@@ -121,6 +127,10 @@ export function setCurrentLocale(locale: string): void {
|
||||
}
|
||||
|
||||
export function onLocaleChange(callback: () => void): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
window.addEventListener(localeChangeEvent, callback);
|
||||
return () => window.removeEventListener(localeChangeEvent, callback);
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { i18n } from './i18n';
|
||||
import { router } from './router';
|
||||
import { setupSeo } from './seo';
|
||||
import './styles/main.css';
|
||||
|
||||
setupSeo(router);
|
||||
createApp(App).use(i18n).use(router).mount('#app');
|
||||
@@ -1,388 +0,0 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import HomeView from '../views/HomeView.vue';
|
||||
import PokemonList from '../views/PokemonList.vue';
|
||||
import PokemonDetail from '../views/PokemonDetail.vue';
|
||||
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 RecipeList from '../views/RecipeList.vue';
|
||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||
import LifePostDetail from '../views/LifePostDetail.vue';
|
||||
import LifeView from '../views/LifeView.vue';
|
||||
import DishView from '../views/DishView.vue';
|
||||
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
|
||||
import LegalView from '../views/LegalView.vue';
|
||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||
import AdminView from '../views/AdminView.vue';
|
||||
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import UserProfileView from '../views/UserProfileView.vue';
|
||||
import RegisterView from '../views/RegisterView.vue';
|
||||
import ResetPasswordView from '../views/ResetPasswordView.vue';
|
||||
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
||||
import { api, getAuthToken, setAuthToken } from '../services/api';
|
||||
import type { RouteSeoConfig } from '../seo';
|
||||
|
||||
const seo = (config: RouteSeoConfig) => config;
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView, meta: { seo: seo({ titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }) } },
|
||||
{
|
||||
path: '/pokemon',
|
||||
name: 'pokemon-list',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: false },
|
||||
meta: { seo: seo({ titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }) }
|
||||
},
|
||||
{
|
||||
path: '/pokemon/new',
|
||||
name: 'pokemon-new',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: false },
|
||||
meta: {
|
||||
requiredPermission: 'pokemon.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/event-pokemon',
|
||||
name: 'event-pokemon-list',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: true },
|
||||
meta: { seo: seo({ titleKey: 'pages.eventPokemon.title', descriptionKey: 'pages.eventPokemon.subtitle', canonicalPath: '/event-pokemon' }) }
|
||||
},
|
||||
{
|
||||
path: '/event-pokemon/new',
|
||||
name: 'event-pokemon-new',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: true },
|
||||
meta: {
|
||||
requiredPermission: 'pokemon.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.eventPokemon.newTitle', descriptionKey: 'pages.eventPokemon.editSubtitle', canonicalPath: '/event-pokemon', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/pokemon/:id/edit',
|
||||
name: 'pokemon-edit',
|
||||
component: PokemonDetail,
|
||||
meta: {
|
||||
requiredPermission: 'pokemon.update',
|
||||
editorModal: true,
|
||||
seo: seo({
|
||||
titleKey: 'pages.pokemon.editKicker',
|
||||
descriptionKey: 'pages.pokemon.editSubtitle',
|
||||
canonicalPath: (route) => `/pokemon/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{ path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail, meta: { seo: seo({ titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }) } },
|
||||
{
|
||||
path: '/habitats',
|
||||
name: 'habitat-list',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: false },
|
||||
meta: { seo: seo({ titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }) }
|
||||
},
|
||||
{
|
||||
path: '/habitats/new',
|
||||
name: 'habitat-new',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: false },
|
||||
meta: {
|
||||
requiredPermission: 'habitats.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/event-habitats',
|
||||
name: 'event-habitat-list',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: true },
|
||||
meta: { seo: seo({ titleKey: 'pages.eventHabitats.title', descriptionKey: 'pages.eventHabitats.subtitle', canonicalPath: '/event-habitats' }) }
|
||||
},
|
||||
{
|
||||
path: '/event-habitats/new',
|
||||
name: 'event-habitat-new',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: true },
|
||||
meta: {
|
||||
requiredPermission: 'habitats.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.eventHabitats.newTitle', descriptionKey: 'pages.eventHabitats.editSubtitle', canonicalPath: '/event-habitats', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/habitats/:id/edit',
|
||||
name: 'habitat-edit',
|
||||
component: HabitatDetail,
|
||||
meta: {
|
||||
requiredPermission: 'habitats.update',
|
||||
editorModal: true,
|
||||
seo: seo({
|
||||
titleKey: 'pages.habitats.detailKicker',
|
||||
descriptionKey: 'pages.habitats.editSubtitle',
|
||||
canonicalPath: (route) => `/habitats/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{ 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,
|
||||
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',
|
||||
component: ItemDetail,
|
||||
meta: {
|
||||
requiredPermission: 'items.update',
|
||||
editorModal: true,
|
||||
seo: seo({
|
||||
titleKey: 'pages.items.editKicker',
|
||||
descriptionKey: 'pages.items.editSubtitle',
|
||||
canonicalPath: (route) => `/items/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{ 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: 'items.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: ItemDetail,
|
||||
meta: {
|
||||
requiredPermission: 'items.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: ItemDetail,
|
||||
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',
|
||||
name: 'recipe-new',
|
||||
component: RecipeList,
|
||||
meta: {
|
||||
requiredPermission: 'recipes.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.recipes.newTitle', descriptionKey: 'pages.recipes.editSubtitle', canonicalPath: '/recipes', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/recipes/:id/edit',
|
||||
name: 'recipe-edit',
|
||||
component: RecipeDetail,
|
||||
meta: {
|
||||
requiredPermission: 'recipes.update',
|
||||
editorModal: true,
|
||||
seo: seo({
|
||||
titleKey: 'pages.recipes.editKicker',
|
||||
descriptionKey: 'pages.recipes.editSubtitle',
|
||||
canonicalPath: (route) => `/recipes/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail, meta: { seo: seo({ titleKey: 'pages.recipes.detailKicker', descriptionKey: 'pages.recipes.subtitle' }) } },
|
||||
{
|
||||
path: '/automation',
|
||||
name: 'automation',
|
||||
component: ComingSoonView,
|
||||
props: { page: 'automation' },
|
||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.automation.title', descriptionKey: 'pages.comingSoon.sections.automation.subtitle', noindex: true }) }
|
||||
},
|
||||
{
|
||||
path: '/dish',
|
||||
name: 'dish',
|
||||
component: DishView,
|
||||
meta: { seo: seo({ titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }) }
|
||||
},
|
||||
{
|
||||
path: '/events',
|
||||
name: 'events',
|
||||
component: ComingSoonView,
|
||||
props: { page: 'events' },
|
||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.events.title', descriptionKey: 'pages.comingSoon.sections.events.subtitle', noindex: true }) }
|
||||
},
|
||||
{
|
||||
path: '/actions',
|
||||
name: 'actions',
|
||||
component: ComingSoonView,
|
||||
props: { page: 'actions' },
|
||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.actions.title', descriptionKey: 'pages.comingSoon.sections.actions.subtitle', noindex: true }) }
|
||||
},
|
||||
{
|
||||
path: '/dream-island',
|
||||
name: 'dream-island',
|
||||
component: ComingSoonView,
|
||||
props: { page: 'dreamIsland' },
|
||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dreamIsland.title', descriptionKey: 'pages.comingSoon.sections.dreamIsland.subtitle', noindex: true }) }
|
||||
},
|
||||
{
|
||||
path: '/clothes',
|
||||
name: 'clothes',
|
||||
component: ComingSoonView,
|
||||
props: { page: 'clothes' },
|
||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.clothes.title', descriptionKey: 'pages.comingSoon.sections.clothes.subtitle', noindex: true }) }
|
||||
},
|
||||
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
|
||||
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
|
||||
{ path: '/life/:id', component: LifePostDetail, meta: { seo: seo({ titleKey: 'pages.life.detailTitle', descriptionKey: 'pages.life.detailSubtitle' }) } },
|
||||
{
|
||||
path: '/project-updates',
|
||||
component: ProjectUpdatesView,
|
||||
meta: {
|
||||
seo: seo({
|
||||
titleKey: 'pages.projectUpdates.title',
|
||||
descriptionKey: 'pages.projectUpdates.subtitle',
|
||||
canonicalPath: '/project-updates'
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/privacy-policy',
|
||||
component: LegalView,
|
||||
props: { page: 'privacy' },
|
||||
meta: { seo: seo({ titleKey: 'pages.legal.privacy.title', descriptionKey: 'pages.legal.privacy.subtitle', canonicalPath: '/privacy-policy' }) }
|
||||
},
|
||||
{
|
||||
path: '/terms-of-service',
|
||||
component: LegalView,
|
||||
props: { page: 'terms' },
|
||||
meta: { seo: seo({ titleKey: 'pages.legal.terms.title', descriptionKey: 'pages.legal.terms.subtitle', canonicalPath: '/terms-of-service' }) }
|
||||
},
|
||||
{
|
||||
path: '/disclaimers',
|
||||
component: LegalView,
|
||||
props: { page: 'disclaimers' },
|
||||
meta: { seo: seo({ titleKey: 'pages.legal.disclaimers.title', descriptionKey: 'pages.legal.disclaimers.subtitle', canonicalPath: '/disclaimers' }) }
|
||||
},
|
||||
{ path: '/admin', component: AdminView, meta: { requiredPermission: 'admin.access', seo: seo({ titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }) } },
|
||||
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true, seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }) } },
|
||||
{ path: '/profile/:id', component: UserProfileView, meta: { seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }) } },
|
||||
{ path: '/login', component: LoginView, meta: { seo: seo({ titleKey: 'auth.loginTitle', descriptionKey: 'auth.loginSubtitle', noindex: true }) } },
|
||||
{ path: '/forgot-password', component: ForgotPasswordView, meta: { seo: seo({ titleKey: 'auth.requestResetTitle', descriptionKey: 'auth.requestResetSubtitle', noindex: true }) } },
|
||||
{ path: '/reset-password', component: ResetPasswordView, meta: { seo: seo({ titleKey: 'auth.resetTitle', descriptionKey: 'auth.resetSubtitle', noindex: true }) } },
|
||||
{ path: '/register', component: RegisterView, meta: { seo: seo({ titleKey: 'auth.registerTitle', descriptionKey: 'auth.registerSubtitle', noindex: true }) } },
|
||||
{ path: '/verify-email', component: VerifyEmailView, meta: { seo: seo({ titleKey: 'auth.verifyTitle', descriptionKey: 'auth.verifySubtitle', noindex: true }) } }
|
||||
],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) return savedPosition;
|
||||
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
|
||||
return { top: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const requiredPermissions = to.matched
|
||||
.map((record) => record.meta.requiredPermission)
|
||||
.filter((permission): permission is string => typeof permission === 'string');
|
||||
const requiredAnyPermissions = to.matched.flatMap((record) =>
|
||||
Array.isArray(record.meta.requiredAnyPermission)
|
||||
? record.meta.requiredAnyPermission.filter((permission): permission is string => typeof permission === 'string')
|
||||
: []
|
||||
);
|
||||
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true) || requiredPermissions.length > 0 || requiredAnyPermissions.length > 0;
|
||||
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
|
||||
|
||||
if (!requiresAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!getAuthToken()) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
if (requiresVerified && !response.user.emailVerified) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
|
||||
const permissionSet = new Set(response.user.permissions);
|
||||
if (requiredPermissions.some((permission) => !permissionSet.has(permission))) {
|
||||
return { path: '/pokemon' };
|
||||
}
|
||||
if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) {
|
||||
return { path: '/pokemon' };
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
setAuthToken(null);
|
||||
return { path: '/login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router';
|
||||
import { getCurrentLocale, i18n, onLocaleChange } from './i18n';
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import { getCurrentLocale, i18n } from './i18n';
|
||||
|
||||
const siteName = 'Pokopia Wiki';
|
||||
const defaultCanonicalPath = '/';
|
||||
const defaultImagePath = '/seo/pokopia-hero.jpg';
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
let runtimeSiteUrl: string | null = null;
|
||||
|
||||
type TranslationValues = Record<string, string | number>;
|
||||
|
||||
@@ -28,10 +29,15 @@ export type SeoConfig = {
|
||||
|
||||
const translate = i18n.global.t as (key: string, values?: TranslationValues) => string;
|
||||
|
||||
export function setConfiguredSiteUrl(value: unknown): void {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
runtimeSiteUrl = normalizeSiteUrl(value);
|
||||
}
|
||||
}
|
||||
|
||||
function configuredSiteUrl(): string {
|
||||
const fromEnv = import.meta.env.VITE_SITE_URL;
|
||||
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') {
|
||||
return normalizeSiteUrl(fromEnv);
|
||||
if (runtimeSiteUrl) {
|
||||
return runtimeSiteUrl;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.location.origin) {
|
||||
@@ -168,15 +174,3 @@ export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
|
||||
noindex: routeSeo?.noindex
|
||||
});
|
||||
}
|
||||
|
||||
export function setupSeo(router: Router): void {
|
||||
router.afterEach((to) => {
|
||||
applyRouteSeo(to);
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
onLocaleChange(() => {
|
||||
applyRouteSeo(router.currentRoute.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getCurrentLocale } from '../i18n';
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
||||
let apiBaseUrl = 'http://localhost:3001';
|
||||
const authTokenKey = 'pokopia_auth_token';
|
||||
const authChangeEvent = 'pokopia-auth-change';
|
||||
|
||||
@@ -15,6 +15,12 @@ export interface Language {
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export function setApiBaseUrl(value: unknown): void {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
apiBaseUrl = value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
export type SystemWordingSurface = 'frontend' | 'backend' | 'email';
|
||||
|
||||
export interface SystemWording {
|
||||
@@ -1057,6 +1063,10 @@ export function setAuthToken(token: string | null, options: { persistent?: boole
|
||||
}
|
||||
|
||||
export function onAuthTokenChange(callback: () => void): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
window.addEventListener(authChangeEvent, callback);
|
||||
return () => window.removeEventListener(authChangeEvent, callback);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createServer } from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dist');
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '.output/public');
|
||||
const indexPath = path.join(root, 'index.html');
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
const port = Number.parseInt(process.env.PORT ?? '20015', 10);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"extends": "./.nuxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"noImplicitOverride": false,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "../system-wordings.ts"]
|
||||
"include": [".nuxt/**/*.d.ts", "**/*.d.ts", "**/*.ts", "**/*.vue", "../system-wordings.ts"]
|
||||
}
|
||||
|
||||
14
frontend/types/page-meta.d.ts
vendored
Normal file
14
frontend/types/page-meta.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { RouteSeoConfig } from '../src/seo';
|
||||
|
||||
declare module '#app' {
|
||||
interface PageMeta {
|
||||
editorModal?: boolean;
|
||||
requiredAnyPermission?: string[];
|
||||
requiredPermission?: string;
|
||||
requiresAuth?: boolean;
|
||||
requiresVerified?: boolean;
|
||||
seo?: RouteSeoConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,120 +0,0 @@
|
||||
import { defineConfig, loadEnv, type PluginOption } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
const frontendPort = 20015;
|
||||
const sitemapPaths = [
|
||||
'/pokemon',
|
||||
'/event-pokemon',
|
||||
'/habitats',
|
||||
'/event-habitats',
|
||||
'/items',
|
||||
'/event-items',
|
||||
'/ancient-artifacts',
|
||||
'/recipes',
|
||||
'/dish',
|
||||
'/checklist',
|
||||
'/life'
|
||||
];
|
||||
const robotsDisallowPaths = [
|
||||
'/admin',
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/verify-email',
|
||||
'/pokemon/new',
|
||||
'/event-pokemon/new',
|
||||
'/pokemon/*/edit',
|
||||
'/habitats/new',
|
||||
'/event-habitats/new',
|
||||
'/habitats/*/edit',
|
||||
'/items/new',
|
||||
'/event-items/new',
|
||||
'/items/*/edit',
|
||||
'/ancient-artifacts/new',
|
||||
'/ancient-artifacts/*/edit',
|
||||
'/recipes/new',
|
||||
'/recipes/*/edit',
|
||||
'/automation',
|
||||
'/events',
|
||||
'/actions',
|
||||
'/dream-island',
|
||||
'/clothes'
|
||||
];
|
||||
|
||||
function normalizeSiteUrl(value: string | undefined): string {
|
||||
return (value?.trim() || fallbackSiteUrl).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function robotsTxt(siteUrl: string): string {
|
||||
const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n');
|
||||
return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`;
|
||||
}
|
||||
|
||||
function sitemapXml(siteUrl: string): string {
|
||||
const urls = sitemapPaths
|
||||
.map(
|
||||
(path) => ` <url>
|
||||
<loc>${siteUrl}${path}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
</url>`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls}
|
||||
</urlset>
|
||||
`;
|
||||
}
|
||||
|
||||
function seoFilesPlugin(siteUrl: string): PluginOption {
|
||||
return {
|
||||
name: 'pokopia-seo-files',
|
||||
transformIndexHtml(html) {
|
||||
return html.replaceAll('%POKOPIA_SITE_URL%', siteUrl);
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
if (request.url === '/robots.txt') {
|
||||
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
response.end(robotsTxt(siteUrl));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.url === '/sitemap.xml') {
|
||||
response.setHeader('Content-Type', 'application/xml; charset=utf-8');
|
||||
response.end(sitemapXml(siteUrl));
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
},
|
||||
generateBundle() {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'robots.txt',
|
||||
source: robotsTxt(siteUrl)
|
||||
});
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'sitemap.xml',
|
||||
source: sitemapXml(siteUrl)
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const siteUrl = normalizeSiteUrl(process.env.VITE_SITE_URL ?? env.VITE_SITE_URL);
|
||||
|
||||
return {
|
||||
plugins: [vue(), seoFilesPlugin(siteUrl)],
|
||||
server: {
|
||||
port: frontendPort
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user