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:
2026-05-06 09:19:23 +08:00
parent c821e9ebba
commit 6e8edbbb09
76 changed files with 7045 additions and 640 deletions

View File

@@ -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

View File

@@ -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>

View 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 };
}
};

View File

@@ -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>

View 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
View 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']
}
}
});

View File

@@ -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"

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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);
});

View File

@@ -0,0 +1,5 @@
import { i18n } from '../src/i18n';
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(i18n);
});

View 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);
});
});

View 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));
});

View 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));
});

View 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>
`;
}

View File

@@ -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);
}

View File

@@ -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');

View File

@@ -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 } };
}
});

View File

@@ -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);
});
}
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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
View 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 {};

View File

@@ -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
}
};
});