Compare commits
104 Commits
36e10a06b0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 26bef1b749 | |||
| 02db73aa4e | |||
| ee054dcd15 | |||
| 575597b146 | |||
| 953b90eba1 | |||
| a781bc559b | |||
| e9d356a656 | |||
| 9db8e60f3d | |||
| 4a7309027a | |||
| 520d988589 | |||
| 64ca494d82 | |||
| cbb101336b | |||
| 23a7301598 | |||
| 515297ab74 | |||
| b1cf40edd0 | |||
| bcf8dd9cb5 | |||
| d87539e897 | |||
| 82f08c1684 | |||
| df78685dc3 | |||
| cc440ea949 | |||
| 5ef1f4ecc9 | |||
| 4dc73d42cb | |||
| fa656a8d02 | |||
| f26cfdc830 | |||
| 71b35b9cc6 | |||
| 70f7a73e6d | |||
| f92e97b747 | |||
| d66124862a | |||
| f7986ca520 | |||
| 425f2f4d5f | |||
| 35ee164794 | |||
| cf1eb6965e | |||
| 337a6bda1f | |||
| fd1f3ef636 | |||
| afed409127 | |||
| 6e8edbbb09 | |||
| c821e9ebba | |||
| 91a001e3f9 | |||
| 22016365d8 | |||
| 5b22d788d7 | |||
| 0e2743b469 | |||
| 5a83a73108 | |||
| 839a24566b | |||
| 9312156a3c | |||
| 8ee29e9549 | |||
| 357dc061d6 | |||
| a17344d216 | |||
| cd0f8868c3 | |||
| 28f4e6032c | |||
| 2220d5d595 | |||
| 2ff2519647 | |||
| 504849c14a | |||
| 8cb8190554 | |||
| 016364a8b8 | |||
| b0e2036965 | |||
| 06e0cbb1c1 | |||
| 3dd3998a5c | |||
| bd944556d9 | |||
| 07698e063d | |||
| 3d6188748d | |||
| a25f1661b5 | |||
| 579d092020 | |||
| 7ff7e18b94 | |||
| bcff83a512 | |||
| 03f5735bd2 | |||
| 4238be7761 | |||
| 5ccc25b248 | |||
| f2a8b67ebf | |||
| fa06d24826 | |||
| 8dfd03f3d2 | |||
| a0e07f101a | |||
| df212a4e27 | |||
| deb0b54e71 | |||
| b0e2464c24 | |||
| 40f85ae85c | |||
| 3a8a61487a | |||
| 72ddae6f9d | |||
| fcb9b57aa3 | |||
| d80c9325cd | |||
| 105274eec8 | |||
| 4ebb45aa94 | |||
| 6758aaaa7e | |||
| 6782ddd101 | |||
| 18baf7b513 | |||
| 590bd6a0ae | |||
| 7aa80430d9 | |||
| 960898c858 | |||
| 0c76d6bfc8 | |||
| 8f55db9061 | |||
| 1dab650c2c | |||
| 282481bbcc | |||
| 0e835f9c03 | |||
| b9ec8076ac | |||
| 043ebe392a | |||
| ef82fc805d | |||
| 95d76522df | |||
| accd6f98cf | |||
| 3ca66d7124 | |||
| 8bc311916d | |||
| 05f531ddf2 | |||
| 05898f9441 | |||
| 3d99f00c75 | |||
| 4d05618530 | |||
| 784cbdacd1 |
29
.env.example
29
.env.example
@@ -3,8 +3,31 @@ POSTGRES_USER=pokopia
|
|||||||
POSTGRES_PASSWORD=pokopia
|
POSTGRES_PASSWORD=pokopia
|
||||||
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
|
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
|
||||||
BACKEND_PORT=3001
|
BACKEND_PORT=3001
|
||||||
FRONTEND_ORIGIN=http://localhost:3000
|
TRUST_PROXY=false
|
||||||
APP_ORIGIN=http://localhost:3000
|
FRONTEND_ORIGIN=http://localhost:20015
|
||||||
VITE_API_BASE_URL=http://localhost:3001
|
APP_ORIGIN=http://localhost:20015
|
||||||
|
BACKEND_PUBLIC_ORIGIN=http://localhost:20016
|
||||||
|
NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
|
||||||
|
NUXT_SERVER_API_BASE_URL=http://localhost:3001
|
||||||
|
NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
|
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
|
||||||
|
RESEND_DAILY_QUOTA_LIMIT=100
|
||||||
|
RESEND_MONTHLY_QUOTA_LIMIT=3000
|
||||||
|
RESEND_QUOTA_RESERVE=5
|
||||||
|
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
|
||||||
|
AI_MODERATION_API_KEY=
|
||||||
|
|
||||||
|
# Local Docker debug defaults:
|
||||||
|
# docker compose -f docker-compose.debug.yml up --build
|
||||||
|
# NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
|
||||||
|
# NUXT_SERVER_API_BASE_URL=http://backend:3001
|
||||||
|
# NUXT_PUBLIC_SITE_URL=http://localhost:20015
|
||||||
|
|
||||||
|
# Cloudflared tunnel deployment example:
|
||||||
|
# FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015
|
||||||
|
# APP_ORIGIN=https://pokopiawiki.tootaio.com
|
||||||
|
# BACKEND_PUBLIC_ORIGIN=https://api-pokopiawiki.tootaio.com
|
||||||
|
# NUXT_PUBLIC_API_BASE_URL=https://api-pokopiawiki.tootaio.com
|
||||||
|
# NUXT_SERVER_API_BASE_URL=http://backend:3001
|
||||||
|
# NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
dist/
|
dist/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
@@ -9,3 +11,4 @@ coverage/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.agents/
|
.agents/
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
repomix-output.xml
|
||||||
1
.repomixignore
Normal file
1
.repomixignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
data/**/*.csv
|
||||||
11
AGENTS.md
11
AGENTS.md
@@ -34,8 +34,8 @@ For documentation-only tasks, still follow the planning workflow, but do not run
|
|||||||
* Runtime baseline: Node.js >= 22.
|
* Runtime baseline: Node.js >= 22.
|
||||||
* Frontend:
|
* Frontend:
|
||||||
|
|
||||||
|
* Nuxt SSR enabled (`ssr: true`)
|
||||||
* Vue
|
* Vue
|
||||||
* Vite
|
|
||||||
* Vue Router
|
* Vue Router
|
||||||
* Vue I18n
|
* Vue I18n
|
||||||
* Iconify
|
* Iconify
|
||||||
@@ -128,6 +128,15 @@ If a task grows beyond scope, STOP and ask.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Git Diff Hygiene
|
||||||
|
|
||||||
|
* Do not inspect, summarize, or report diffs for `data/**/*.csv` by default.
|
||||||
|
* In WSL, CSV files under `data` may appear changed even when their content has not meaningfully changed.
|
||||||
|
* Ignore `data/**/*.csv` entries in `git status` / `git diff` unless the task explicitly involves CSV data, import/seed data, or the user asks to check them.
|
||||||
|
* Only mention CSV files in final change summaries if you intentionally changed them or verified they are relevant to the current task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## UI Safety Rules (CRITICAL)
|
## UI Safety Rules (CRITICAL)
|
||||||
|
|
||||||
User-facing UI must NEVER contain:
|
User-facing UI must NEVER contain:
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY backend/package.json ./backend/package.json
|
||||||
|
COPY frontend/package.json ./frontend/package.json
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install --frozen-lockfile --filter @pokopia/backend...
|
||||||
|
COPY backend ./backend
|
||||||
|
COPY data ./data
|
||||||
|
COPY system-wordings.ts ./system-wordings.ts
|
||||||
|
RUN mkdir -p /app/uploads && chown -R node:node /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
COPY backend/package.json ./
|
USER node
|
||||||
RUN corepack enable && pnpm install
|
|
||||||
COPY backend/. .
|
|
||||||
COPY data /app/data
|
|
||||||
COPY package.json /app/package.json
|
|
||||||
COPY system-wordings.ts /app/system-wordings.ts
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["pnpm", "run", "start"]
|
CMD ["pnpm", "run", "start"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,14 +13,17 @@
|
|||||||
"test": "node --test --import tsx tests/*.test.ts"
|
"test": "node --test --import tsx tests/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "latest",
|
"@fastify/cors": "11.2.0",
|
||||||
"fastify": "latest",
|
"@fastify/multipart": "10.0.0",
|
||||||
"pg": "latest"
|
"@fastify/rate-limit": "10.3.0",
|
||||||
|
"@fastify/static": "9.1.3",
|
||||||
|
"fastify": "5.8.5",
|
||||||
|
"pg": "8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "latest",
|
"@types/node": "25.6.0",
|
||||||
"@types/pg": "latest",
|
"@types/pg": "8.20.0",
|
||||||
"tsx": "latest",
|
"tsx": "4.21.0",
|
||||||
"typescript": "latest"
|
"typescript": "6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1206
backend/src/aiModeration.ts
Normal file
1206
backend/src/aiModeration.ts
Normal file
File diff suppressed because it is too large
Load Diff
1487
backend/src/auth.ts
1487
backend/src/auth.ts
File diff suppressed because it is too large
Load Diff
1002
backend/src/notifications.ts
Normal file
1002
backend/src/notifications.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,56 +21,6 @@ const wordingKeyPattern = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$/;
|
|||||||
const placeholderPattern = /\{([A-Za-z0-9_]+)\}/g;
|
const placeholderPattern = /\{([A-Za-z0-9_]+)\}/g;
|
||||||
const surfaces = new Set<SystemWordingSurface>(['frontend', 'backend', 'email']);
|
const surfaces = new Set<SystemWordingSurface>(['frontend', 'backend', 'email']);
|
||||||
|
|
||||||
const legacyMessageKeys = new Map<string, string>([
|
|
||||||
['Record does not exist', 'server.validation.recordMissing'],
|
|
||||||
['Language code is invalid', 'server.validation.languageCodeInvalid'],
|
|
||||||
['Language name is required', 'server.validation.languageNameRequired'],
|
|
||||||
['Default language must be English', 'server.validation.defaultLanguageMustBeEnglish'],
|
|
||||||
['Default language must be enabled', 'server.validation.defaultLanguageMustBeEnabled'],
|
|
||||||
['Language not found', 'server.validation.languageNotFound'],
|
|
||||||
['A default language is required', 'server.validation.defaultLanguageRequired'],
|
|
||||||
['Default language cannot be deleted', 'server.validation.defaultLanguageCannotBeDeleted'],
|
|
||||||
['Please select a language', 'server.validation.selectLanguage'],
|
|
||||||
['Language does not exist', 'server.validation.languageDoesNotExist'],
|
|
||||||
['Pokemon identifier is required', 'server.validation.pokemonIdentifierRequired'],
|
|
||||||
['Pokemon type data is unavailable', 'server.validation.pokemonTypeDataUnavailable'],
|
|
||||||
['Pokemon data was not found', 'server.validation.pokemonDataNotFound'],
|
|
||||||
['Pokemon image path is invalid', 'server.validation.pokemonImagePathInvalid'],
|
|
||||||
['Please enter a task', 'server.validation.taskRequired'],
|
|
||||||
['Please select a task', 'server.validation.selectTask'],
|
|
||||||
['Task does not exist', 'server.validation.taskDoesNotExist'],
|
|
||||||
['Please enter a post', 'server.validation.postRequired'],
|
|
||||||
['Post is too long', 'server.validation.postTooLong'],
|
|
||||||
['Please enter a comment', 'server.validation.commentRequired'],
|
|
||||||
['Comment is too long', 'server.validation.commentTooLong'],
|
|
||||||
['Reaction is invalid', 'server.validation.reactionInvalid'],
|
|
||||||
['Cursor is invalid', 'server.validation.cursorInvalid'],
|
|
||||||
['Tag is invalid', 'server.validation.tagInvalid'],
|
|
||||||
['Entity type is invalid', 'server.validation.entityTypeInvalid'],
|
|
||||||
['Record is invalid', 'server.validation.recordInvalid'],
|
|
||||||
['Comment is invalid', 'server.validation.commentInvalid'],
|
|
||||||
['Please select a record', 'server.validation.selectRecord'],
|
|
||||||
['Choose at least 1 type', 'server.validation.typeMin'],
|
|
||||||
['Choose at most 2 types', 'server.validation.typeMax'],
|
|
||||||
['Choose at most 2 specialities', 'server.validation.skillMax'],
|
|
||||||
['Choose at most 6 favourites', 'server.validation.favoriteMax'],
|
|
||||||
['Drop items must be linked to selected specialities', 'server.validation.dropItemSelectedSkill'],
|
|
||||||
['Pokemon ID is required', 'server.validation.pokemonIdRequired'],
|
|
||||||
['Pokemon name is required', 'server.validation.pokemonNameRequired'],
|
|
||||||
['Height must be a non-negative number', 'server.validation.heightNonNegative'],
|
|
||||||
['Weight must be a non-negative number', 'server.validation.weightNonNegative'],
|
|
||||||
['Ideal Habitat is required', 'server.validation.environmentRequired'],
|
|
||||||
['This speciality cannot have a drop item', 'server.validation.skillNoDrop'],
|
|
||||||
['Habitat name is required', 'server.validation.habitatNameRequired'],
|
|
||||||
['Usage is required', 'server.validation.usageRequired'],
|
|
||||||
['Item name is required', 'server.validation.itemNameRequired'],
|
|
||||||
['Category is required', 'server.validation.categoryRequired'],
|
|
||||||
['An item with a recipe cannot be marked as recipe-free', 'server.validation.recipeFreeWithRecipe'],
|
|
||||||
['Item is required', 'server.validation.itemRequired'],
|
|
||||||
['This item is marked as recipe-free', 'server.validation.recipeFreeItem'],
|
|
||||||
['Name is required', 'server.validation.nameRequired']
|
|
||||||
]);
|
|
||||||
|
|
||||||
function validationError(message: string): ValidationError {
|
function validationError(message: string): ValidationError {
|
||||||
const error = new Error(message) as ValidationError;
|
const error = new Error(message) as ValidationError;
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
@@ -145,22 +95,6 @@ function normalizePlaceholders(value: unknown): string[] {
|
|||||||
return Array.isArray(value) ? value.map((item) => String(item)).sort() : [];
|
return Array.isArray(value) ? value.map((item) => String(item)).sort() : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function legacyMessageKey(message: string): string | null {
|
|
||||||
if (message.startsWith('server.') || message.startsWith('email.')) {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
if (message.endsWith(' must be a non-negative integer')) {
|
|
||||||
return 'server.validation.statNonNegative';
|
|
||||||
}
|
|
||||||
if (message.endsWith(' is empty')) {
|
|
||||||
return 'server.validation.pokemonDataFileEmpty';
|
|
||||||
}
|
|
||||||
if (message.startsWith('Pokemon data file ') && message.endsWith(' is unavailable')) {
|
|
||||||
return 'server.validation.pokemonDataFileUnavailable';
|
|
||||||
}
|
|
||||||
return legacyMessageKeys.get(message) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncSystemWordingCatalog(): Promise<void> {
|
export async function syncSystemWordingCatalog(): Promise<void> {
|
||||||
const entries = systemWordingCatalogEntries();
|
const entries = systemWordingCatalogEntries();
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
@@ -232,8 +166,7 @@ export async function systemMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function localizedStatusMessage(locale: string, message: string): Promise<string> {
|
export async function localizedStatusMessage(locale: string, message: string): Promise<string> {
|
||||||
const key = legacyMessageKey(message);
|
return message.startsWith('server.') || message.startsWith('email.') ? systemMessage(locale, message) : message;
|
||||||
return key ? systemMessage(locale, key) : message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSystemWordings(locale: string) {
|
export async function getSystemWordings(locale: string) {
|
||||||
|
|||||||
442
backend/src/threadsRealtime.ts
Normal file
442
backend/src/threadsRealtime.ts
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import type { Duplex } from 'node:stream';
|
||||||
|
import { pool, query, queryOne } from './db.ts';
|
||||||
|
import type { ThreadMessage, ThreadReactionCounts, ThreadReactionType, ThreadSummary } from './queries.ts';
|
||||||
|
|
||||||
|
export type ThreadWsMessage =
|
||||||
|
| { type: 'threads.connected'; followedUnreadCount: number }
|
||||||
|
| { type: 'thread.message.created'; threadId: number; message: ThreadMessage; thread: ThreadSummary }
|
||||||
|
| { type: 'thread.message.moderation'; threadId: number; messageId: number; message: ThreadMessage | null }
|
||||||
|
| {
|
||||||
|
type: 'thread.reactions.updated';
|
||||||
|
target: 'thread' | 'message';
|
||||||
|
threadId: number;
|
||||||
|
messageId: number | null;
|
||||||
|
reactionCounts: ThreadReactionCounts;
|
||||||
|
myReactions: ThreadReactionType[];
|
||||||
|
}
|
||||||
|
| { type: 'thread.read.updated'; threadId: number; unread: boolean; unreadCount: number };
|
||||||
|
|
||||||
|
const websocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||||
|
const websocketTicketMinutes = 2;
|
||||||
|
const threadClients = new Map<number, Set<Duplex>>();
|
||||||
|
const clientUsers = new WeakMap<Duplex, number>();
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createThreadWebSocketTicket(userId: number): Promise<{ ticket: string; expiresAt: Date }> {
|
||||||
|
const ticket = randomBytes(32).toString('base64url');
|
||||||
|
const expiresAt = new Date(Date.now() + websocketTicketMinutes * 60_000);
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO thread_ws_tickets (ticket_hash, user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`,
|
||||||
|
[hashToken(ticket), userId, expiresAt]
|
||||||
|
);
|
||||||
|
await pool.query('DELETE FROM thread_ws_tickets WHERE expires_at < now()');
|
||||||
|
return { ticket, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeThreadWebSocketTicket(ticket: string): Promise<number | null> {
|
||||||
|
if (!ticket) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await queryOne<{ userId: number }>(
|
||||||
|
`
|
||||||
|
DELETE FROM thread_ws_tickets
|
||||||
|
WHERE ticket_hash = $1
|
||||||
|
AND expires_at > now()
|
||||||
|
RETURNING user_id AS "userId"
|
||||||
|
`,
|
||||||
|
[hashToken(ticket)]
|
||||||
|
);
|
||||||
|
return row?.userId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function followedUnreadCount(userId: number): Promise<number> {
|
||||||
|
const row = await queryOne<{ count: number }>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::integer AS count
|
||||||
|
FROM thread_follows tf
|
||||||
|
JOIN threads t ON t.id = tf.thread_id
|
||||||
|
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = tf.user_id
|
||||||
|
WHERE tf.user_id = $1
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND t.last_message_id IS NOT NULL
|
||||||
|
AND (
|
||||||
|
tr.last_read_message_id IS NULL
|
||||||
|
OR t.last_message_id > tr.last_read_message_id
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return row?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsFrame(data: Buffer, opcode = 0x1): Buffer {
|
||||||
|
const length = data.byteLength;
|
||||||
|
if (length < 126) {
|
||||||
|
return Buffer.concat([Buffer.from([0x80 | opcode, length]), data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length < 65536) {
|
||||||
|
const header = Buffer.alloc(4);
|
||||||
|
header[0] = 0x80 | opcode;
|
||||||
|
header[1] = 126;
|
||||||
|
header.writeUInt16BE(length, 2);
|
||||||
|
return Buffer.concat([header, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = Buffer.alloc(10);
|
||||||
|
header[0] = 0x80 | opcode;
|
||||||
|
header[1] = 127;
|
||||||
|
header.writeBigUInt64BE(BigInt(length), 2);
|
||||||
|
return Buffer.concat([header, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendWsJson(socket: Duplex, message: ThreadWsMessage): void {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.write(wsFrame(Buffer.from(JSON.stringify(message), 'utf8')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function websocketPayload(buffer: Buffer): { opcode: number; payload: Buffer } | null {
|
||||||
|
if (buffer.byteLength < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opcode = buffer[0] & 0x0f;
|
||||||
|
const masked = (buffer[1] & 0x80) !== 0;
|
||||||
|
let length = buffer[1] & 0x7f;
|
||||||
|
let offset = 2;
|
||||||
|
|
||||||
|
if (length === 126) {
|
||||||
|
if (buffer.byteLength < offset + 2) return null;
|
||||||
|
length = buffer.readUInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
} else if (length === 127) {
|
||||||
|
if (buffer.byteLength < offset + 8) return null;
|
||||||
|
const longLength = buffer.readBigUInt64BE(offset);
|
||||||
|
if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) return null;
|
||||||
|
length = Number(longLength);
|
||||||
|
offset += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mask: Buffer | null = null;
|
||||||
|
if (masked) {
|
||||||
|
if (buffer.byteLength < offset + 4) return null;
|
||||||
|
mask = buffer.subarray(offset, offset + 4);
|
||||||
|
offset += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.byteLength < offset + length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = Buffer.from(buffer.subarray(offset, offset + length));
|
||||||
|
if (mask) {
|
||||||
|
for (let index = 0; index < payload.byteLength; index += 1) {
|
||||||
|
payload[index] ^= mask[index % 4];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { opcode, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSocket(socket: Duplex, statusCode = 1000): void {
|
||||||
|
if (socket.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = Buffer.alloc(2);
|
||||||
|
payload.writeUInt16BE(statusCode, 0);
|
||||||
|
socket.end(wsFrame(payload, 0x8));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectUpgrade(socket: Duplex, statusCode: number, statusText: string): void {
|
||||||
|
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\n\r\n`);
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addThreadClient(userId: number, socket: Duplex): void {
|
||||||
|
clientUsers.set(socket, userId);
|
||||||
|
let clients = threadClients.get(userId);
|
||||||
|
if (!clients) {
|
||||||
|
clients = new Set();
|
||||||
|
threadClients.set(userId, clients);
|
||||||
|
}
|
||||||
|
clients.add(socket);
|
||||||
|
socket.on('close', () => {
|
||||||
|
clients?.delete(socket);
|
||||||
|
if (clients?.size === 0) {
|
||||||
|
threadClients.delete(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recipientUserIds(threadId: number): Promise<number[]> {
|
||||||
|
const rows = await query<{ userId: number }>(
|
||||||
|
`
|
||||||
|
SELECT DISTINCT user_id AS "userId"
|
||||||
|
FROM thread_follows
|
||||||
|
WHERE thread_id = $1
|
||||||
|
`,
|
||||||
|
[threadId]
|
||||||
|
);
|
||||||
|
return rows.map((row) => row.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectedUserIds(): number[] {
|
||||||
|
return [...threadClients.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishToUsers(userIds: number[], message: ThreadWsMessage): Promise<void> {
|
||||||
|
for (const userId of userIds) {
|
||||||
|
const clients = threadClients.get(userId);
|
||||||
|
if (!clients) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const socket of clients) {
|
||||||
|
sendWsJson(socket, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadMessageCreated(thread: ThreadSummary, message: ThreadMessage): Promise<void> {
|
||||||
|
const users = [...new Set([...(await recipientUserIds(thread.id)), ...connectedUserIds()])];
|
||||||
|
if (message.author?.id && !users.includes(message.author.id)) {
|
||||||
|
users.push(message.author.id);
|
||||||
|
}
|
||||||
|
await publishToUsers(users, {
|
||||||
|
type: 'thread.message.created',
|
||||||
|
threadId: thread.id,
|
||||||
|
message,
|
||||||
|
thread
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyApprovedThreadMessage(messageId: number): Promise<void> {
|
||||||
|
const row = await queryOne<{
|
||||||
|
threadId: number;
|
||||||
|
channelId: number;
|
||||||
|
title: string;
|
||||||
|
languageCode: string;
|
||||||
|
locked: boolean;
|
||||||
|
messageCount: number;
|
||||||
|
lastActiveAt: Date;
|
||||||
|
threadCreatedAt: Date;
|
||||||
|
threadAuthor: { id: number; displayName: string } | null;
|
||||||
|
messageBody: string;
|
||||||
|
moderationStatus: ThreadMessage['moderationStatus'];
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
moderationReason: string | null;
|
||||||
|
messageCreatedAt: Date;
|
||||||
|
messageUpdatedAt: Date;
|
||||||
|
messageAuthor: { id: number; displayName: string } | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
WITH updated_thread AS (
|
||||||
|
UPDATE threads t
|
||||||
|
SET last_message_id = tm.id,
|
||||||
|
message_count = (
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM thread_messages visible_message
|
||||||
|
WHERE visible_message.thread_id = t.id
|
||||||
|
AND visible_message.deleted_at IS NULL
|
||||||
|
AND visible_message.ai_moderation_status = 'approved'
|
||||||
|
),
|
||||||
|
last_active_at = GREATEST(t.last_active_at, tm.created_at),
|
||||||
|
updated_at = now()
|
||||||
|
FROM thread_messages tm
|
||||||
|
WHERE tm.id = $1
|
||||||
|
AND tm.thread_id = t.id
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
AND tm.ai_moderation_status = 'approved'
|
||||||
|
RETURNING
|
||||||
|
t.id,
|
||||||
|
t.channel_id,
|
||||||
|
t.title,
|
||||||
|
t.language_code,
|
||||||
|
t.locked,
|
||||||
|
t.message_count,
|
||||||
|
t.last_active_at,
|
||||||
|
t.created_at,
|
||||||
|
t.created_by_user_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ut.id AS "threadId",
|
||||||
|
ut.channel_id AS "channelId",
|
||||||
|
ut.title,
|
||||||
|
ut.language_code AS "languageCode",
|
||||||
|
ut.locked,
|
||||||
|
ut.message_count AS "messageCount",
|
||||||
|
ut.last_active_at AS "lastActiveAt",
|
||||||
|
ut.created_at AS "threadCreatedAt",
|
||||||
|
CASE WHEN thread_user.id IS NULL THEN NULL ELSE json_build_object('id', thread_user.id, 'displayName', thread_user.display_name) END AS "threadAuthor",
|
||||||
|
tm.body AS "messageBody",
|
||||||
|
tm.ai_moderation_status AS "moderationStatus",
|
||||||
|
tm.ai_moderation_language_code AS "moderationLanguageCode",
|
||||||
|
tm.ai_moderation_reason AS "moderationReason",
|
||||||
|
tm.created_at AS "messageCreatedAt",
|
||||||
|
tm.updated_at AS "messageUpdatedAt",
|
||||||
|
CASE WHEN message_user.id IS NULL THEN NULL ELSE json_build_object('id', message_user.id, 'displayName', message_user.display_name) END AS "messageAuthor"
|
||||||
|
FROM updated_thread ut
|
||||||
|
JOIN thread_messages tm ON tm.id = $1
|
||||||
|
LEFT JOIN users thread_user ON thread_user.id = ut.created_by_user_id
|
||||||
|
LEFT JOIN users message_user ON message_user.id = tm.created_by_user_id
|
||||||
|
`,
|
||||||
|
[messageId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishThreadMessageCreated(
|
||||||
|
{
|
||||||
|
id: row.threadId,
|
||||||
|
channelId: row.channelId,
|
||||||
|
title: row.title,
|
||||||
|
languageCode: row.languageCode,
|
||||||
|
tags: [],
|
||||||
|
locked: row.locked,
|
||||||
|
messageCount: row.messageCount,
|
||||||
|
lastActiveAt: row.lastActiveAt,
|
||||||
|
createdAt: row.threadCreatedAt,
|
||||||
|
author: row.threadAuthor,
|
||||||
|
reactionCounts: {},
|
||||||
|
myReactions: [],
|
||||||
|
followed: true,
|
||||||
|
unread: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: messageId,
|
||||||
|
threadId: row.threadId,
|
||||||
|
body: row.messageBody,
|
||||||
|
moderationStatus: row.moderationStatus,
|
||||||
|
moderationLanguageCode: row.moderationLanguageCode,
|
||||||
|
moderationReason: row.moderationReason,
|
||||||
|
createdAt: row.messageCreatedAt,
|
||||||
|
updatedAt: row.messageUpdatedAt,
|
||||||
|
author: row.messageAuthor,
|
||||||
|
reactionCounts: {},
|
||||||
|
myReactions: []
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadMessageModeration(
|
||||||
|
threadId: number,
|
||||||
|
messageId: number,
|
||||||
|
message: ThreadMessage | null
|
||||||
|
): Promise<void> {
|
||||||
|
const publicUsers = new Set([...(await recipientUserIds(threadId)), ...connectedUserIds()]);
|
||||||
|
if (message?.author?.id) {
|
||||||
|
publicUsers.delete(message.author.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishToUsers([...publicUsers], {
|
||||||
|
type: 'thread.message.moderation',
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message?.author?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishToUsers([message.author.id], {
|
||||||
|
type: 'thread.message.moderation',
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadReactionUpdated(
|
||||||
|
userId: number,
|
||||||
|
message: Extract<ThreadWsMessage, { type: 'thread.reactions.updated' }>
|
||||||
|
): Promise<void> {
|
||||||
|
const users = await recipientUserIds(message.threadId);
|
||||||
|
for (const connectedUserId of connectedUserIds()) {
|
||||||
|
if (!users.includes(connectedUserId)) {
|
||||||
|
users.push(connectedUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!users.includes(userId)) {
|
||||||
|
users.push(userId);
|
||||||
|
}
|
||||||
|
await publishToUsers(users, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadReadUpdated(userId: number, threadId: number, unread: boolean, unreadCount: number): Promise<void> {
|
||||||
|
await publishToUsers([userId], { type: 'thread.read.updated', threadId, unread, unreadCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupThreadWebSocketServer(server: Server, logger: FastifyBaseLogger): void {
|
||||||
|
server.on('upgrade', async (request, socket) => {
|
||||||
|
const url = new URL(request.url ?? '/', 'http://localhost');
|
||||||
|
if (url.pathname !== '/api/threads/ws') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = request.headers['sec-websocket-key'];
|
||||||
|
if (request.method !== 'GET' || typeof key !== 'string' || key.trim() === '') {
|
||||||
|
rejectUpgrade(socket, 400, 'Bad Request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ticket = url.searchParams.get('ticket') ?? '';
|
||||||
|
const userId = await consumeThreadWebSocketTicket(ticket);
|
||||||
|
if (!userId) {
|
||||||
|
rejectUpgrade(socket, 401, 'Unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accept = createHash('sha1').update(`${key}${websocketGuid}`).digest('base64');
|
||||||
|
socket.write(
|
||||||
|
[
|
||||||
|
'HTTP/1.1 101 Switching Protocols',
|
||||||
|
'Upgrade: websocket',
|
||||||
|
'Connection: Upgrade',
|
||||||
|
`Sec-WebSocket-Accept: ${accept}`,
|
||||||
|
'\r\n'
|
||||||
|
].join('\r\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
addThreadClient(userId, socket);
|
||||||
|
sendWsJson(socket, {
|
||||||
|
type: 'threads.connected',
|
||||||
|
followedUnreadCount: await followedUnreadCount(userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('data', (buffer: Buffer) => {
|
||||||
|
const frame = websocketPayload(buffer);
|
||||||
|
if (!frame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.opcode === 0x8) {
|
||||||
|
closeSocket(socket);
|
||||||
|
} else if (frame.opcode === 0x9) {
|
||||||
|
socket.write(wsFrame(frame.payload, 0x0a));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('error', () => {
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error }, 'Thread WebSocket upgrade failed');
|
||||||
|
rejectUpgrade(socket, 500, 'Internal Server Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
265
backend/src/uploads.ts
Normal file
265
backend/src/uploads.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { MultipartFile } from '@fastify/multipart';
|
||||||
|
import type { PoolClient } from 'pg';
|
||||||
|
import type { AuthUser } from './auth.ts';
|
||||||
|
import { query, queryOne } from './db.ts';
|
||||||
|
|
||||||
|
export type UploadEntityType = 'pokemon' | 'items' | 'habitats' | 'ancient-artifacts';
|
||||||
|
|
||||||
|
export type EntityImageUpload = {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
url: string;
|
||||||
|
uploadedAt: Date;
|
||||||
|
uploadedBy: { id: number; displayName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadRow = {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
uploadedAt: Date;
|
||||||
|
uploadedBy: { id: number; displayName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MultipartField = {
|
||||||
|
value?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadEntityTypes = new Set<UploadEntityType>(['pokemon', 'items', 'habitats', 'ancient-artifacts']);
|
||||||
|
const imageMimeTypes = new Map([
|
||||||
|
['image/png', '.png'],
|
||||||
|
['image/jpeg', '.jpg'],
|
||||||
|
['image/webp', '.webp'],
|
||||||
|
['image/gif', '.gif']
|
||||||
|
]);
|
||||||
|
|
||||||
|
const backendPublicOrigin = process.env.BACKEND_PUBLIC_ORIGIN ?? `http://localhost:${process.env.BACKEND_PORT ?? 3001}`;
|
||||||
|
export const imageUploadMaxBytes = 3 * 1024 * 1024;
|
||||||
|
export const uploadRoot = path.resolve(process.env.UPLOAD_DIR ?? path.join(process.cwd(), 'uploads'));
|
||||||
|
export const uploadPublicBaseUrl = (process.env.UPLOAD_PUBLIC_BASE_URL ?? `${backendPublicOrigin}/uploads/`).replace(/\/?$/, '/');
|
||||||
|
|
||||||
|
export function isUploadEntityType(value: string): value is UploadEntityType {
|
||||||
|
return uploadEntityTypes.has(value as UploadEntityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUploadImagePath(value: string | null | undefined): boolean {
|
||||||
|
const cleanPath = value?.trim() ?? '';
|
||||||
|
if (cleanPath === '' || cleanPath.startsWith('/') || cleanPath.includes('..')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [entityType] = cleanPath.split('/');
|
||||||
|
return isUploadEntityType(entityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadImageUrl(relativePath: string): string {
|
||||||
|
return `${uploadPublicBaseUrl}${relativePath.split('/').map(encodeURIComponent).join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validationError(message: string): Error & { statusCode: number } {
|
||||||
|
const error = new Error(message) as Error & { statusCode: number };
|
||||||
|
error.statusCode = 400;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldValue(fields: Record<string, unknown> | undefined, fieldName: string): string {
|
||||||
|
const field = fields?.[fieldName];
|
||||||
|
if (field && typeof field === 'object' && 'value' in field) {
|
||||||
|
const value = (field as MultipartField).value;
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalPositiveInteger(value: string): number | null {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePathSegment(value: string): string {
|
||||||
|
const segment = value
|
||||||
|
.normalize('NFKC')
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|#%?&\u0000-\u001F]+/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/^\.+$/, '')
|
||||||
|
.slice(0, 80);
|
||||||
|
|
||||||
|
return segment || 'record';
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampForPath(date = new Date()): string {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0');
|
||||||
|
return [
|
||||||
|
date.getUTCFullYear(),
|
||||||
|
pad(date.getUTCMonth() + 1),
|
||||||
|
pad(date.getUTCDate()),
|
||||||
|
pad(date.getUTCHours()),
|
||||||
|
pad(date.getUTCMinutes()),
|
||||||
|
pad(date.getUTCSeconds())
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uniqueRelativePath(entityType: UploadEntityType, entityName: string, extension: string): Promise<string> {
|
||||||
|
const entitySegment = safePathSegment(entityName);
|
||||||
|
const dir = path.join(uploadRoot, entityType, entitySegment);
|
||||||
|
const timestamp = timestampForPath();
|
||||||
|
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
for (let index = 1; index <= 99; index += 1) {
|
||||||
|
const suffix = index === 1 ? '' : `-${index}`;
|
||||||
|
const fileName = `${timestamp}${suffix}${extension}`;
|
||||||
|
const candidate = path.join(dir, fileName);
|
||||||
|
if (!(await fileExists(candidate))) {
|
||||||
|
return `${entityType}/${entitySegment}/${fileName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw validationError('server.validation.imageUploadFailed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidImageSignature(mimeType: string, buffer: Buffer): boolean {
|
||||||
|
if (mimeType === 'image/png') {
|
||||||
|
return buffer.length > 8 && buffer[0] === 0x89 && buffer.subarray(1, 4).toString('ascii') === 'PNG';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType === 'image/jpeg') {
|
||||||
|
return buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType === 'image/webp') {
|
||||||
|
return buffer.length > 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType === 'image/gif') {
|
||||||
|
const signature = buffer.subarray(0, 6).toString('ascii');
|
||||||
|
return signature === 'GIF87a' || signature === 'GIF89a';
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUploadRow(row: UploadRow): EntityImageUpload {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
path: row.path,
|
||||||
|
uploadedAt: row.uploadedAt,
|
||||||
|
uploadedBy: row.uploadedBy,
|
||||||
|
url: uploadImageUrl(row.path)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEntityImageUpload(
|
||||||
|
entityType: UploadEntityType,
|
||||||
|
file: MultipartFile | undefined,
|
||||||
|
user: AuthUser
|
||||||
|
): Promise<EntityImageUpload> {
|
||||||
|
if (!file) {
|
||||||
|
throw validationError('server.validation.imageUploadRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = imageMimeTypes.get(file.mimetype);
|
||||||
|
if (!extension) {
|
||||||
|
throw validationError('server.validation.imageUploadTypeInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityName = fieldValue(file.fields as Record<string, unknown>, 'entityName');
|
||||||
|
if (entityName === '') {
|
||||||
|
throw validationError('server.validation.imageUploadEntityNameRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await file.toBuffer();
|
||||||
|
if (buffer.length === 0 || buffer.length > imageUploadMaxBytes || !hasValidImageSignature(file.mimetype, buffer)) {
|
||||||
|
throw validationError('server.validation.imageUploadContentInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityId = optionalPositiveInteger(fieldValue(file.fields as Record<string, unknown>, 'entityId'));
|
||||||
|
const relativePath = await uniqueRelativePath(entityType, entityName, extension);
|
||||||
|
const absolutePath = path.join(uploadRoot, relativePath);
|
||||||
|
await writeFile(absolutePath, buffer, { flag: 'wx' });
|
||||||
|
|
||||||
|
const row = await queryOne<UploadRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO entity_image_uploads (
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
entity_name,
|
||||||
|
path,
|
||||||
|
original_filename,
|
||||||
|
mime_type,
|
||||||
|
byte_size,
|
||||||
|
created_by_user_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
created_at AS "uploadedAt",
|
||||||
|
json_build_object('id', $8::integer, 'displayName', $9::text) AS "uploadedBy"
|
||||||
|
`,
|
||||||
|
[entityType, entityId, entityName.trim(), relativePath, file.filename, file.mimetype, buffer.length, user.id, user.displayName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw validationError('server.validation.imageUploadFailed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapUploadRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEntityImageUploads(entityType: UploadEntityType, entityId: number): Promise<EntityImageUpload[]> {
|
||||||
|
const rows = await query<UploadRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
upload.id,
|
||||||
|
upload.path,
|
||||||
|
upload.created_at AS "uploadedAt",
|
||||||
|
CASE
|
||||||
|
WHEN u.id IS NULL THEN NULL
|
||||||
|
ELSE json_build_object('id', u.id, 'displayName', u.display_name)
|
||||||
|
END AS "uploadedBy"
|
||||||
|
FROM entity_image_uploads upload
|
||||||
|
LEFT JOIN users u ON u.id = upload.created_by_user_id
|
||||||
|
WHERE upload.entity_type = $1
|
||||||
|
AND upload.entity_id = $2
|
||||||
|
ORDER BY upload.created_at DESC, upload.id DESC
|
||||||
|
`,
|
||||||
|
[entityType, entityId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(mapUploadRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function linkEntityImageUpload(
|
||||||
|
client: PoolClient,
|
||||||
|
entityType: UploadEntityType,
|
||||||
|
entityId: number,
|
||||||
|
imagePath: string | null | undefined,
|
||||||
|
entityName: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isUploadImagePath(imagePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE entity_image_uploads
|
||||||
|
SET entity_id = $1,
|
||||||
|
entity_name = $2
|
||||||
|
WHERE entity_type = $3
|
||||||
|
AND path = $4
|
||||||
|
`,
|
||||||
|
[entityId, entityName.trim(), entityType, imagePath]
|
||||||
|
);
|
||||||
|
}
|
||||||
108
docker-compose.debug.yml
Normal file
108
docker-compose.debug.yml
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pokopia
|
||||||
|
POSTGRES_USER: pokopia
|
||||||
|
POSTGRES_PASSWORD: pokopia
|
||||||
|
volumes:
|
||||||
|
- postgres18_data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pokopia -d pokopia"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
deps:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
PNPM_HOME: /pnpm
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- root_node_modules:/app/node_modules
|
||||||
|
- backend_node_modules:/app/backend/node_modules
|
||||||
|
- frontend_node_modules:/app/frontend/node_modules
|
||||||
|
- pnpm_store:/pnpm/store
|
||||||
|
command: >
|
||||||
|
sh -lc "corepack enable &&
|
||||||
|
corepack prepare pnpm@10.33.2 --activate &&
|
||||||
|
pnpm config set store-dir /pnpm/store &&
|
||||||
|
pnpm install --frozen-lockfile"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PNPM_HOME: /pnpm
|
||||||
|
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
|
||||||
|
BACKEND_PORT: 3001
|
||||||
|
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||||
|
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:20015}
|
||||||
|
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:20015}
|
||||||
|
UPLOAD_DIR: /app/uploads
|
||||||
|
BACKEND_PUBLIC_ORIGIN: ${BACKEND_PUBLIC_ORIGIN:-http://localhost:20016}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
|
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
|
||||||
|
RESEND_DAILY_QUOTA_LIMIT: ${RESEND_DAILY_QUOTA_LIMIT:-100}
|
||||||
|
RESEND_MONTHLY_QUOTA_LIMIT: ${RESEND_MONTHLY_QUOTA_LIMIT:-3000}
|
||||||
|
RESEND_QUOTA_RESERVE: ${RESEND_QUOTA_RESERVE:-5}
|
||||||
|
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES: ${RESEND_QUOTA_SNAPSHOT_TTL_MINUTES:-10}
|
||||||
|
AI_MODERATION_API_KEY: ${AI_MODERATION_API_KEY:-}
|
||||||
|
ports:
|
||||||
|
- "20016:3001"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- root_node_modules:/app/node_modules
|
||||||
|
- backend_node_modules:/app/backend/node_modules
|
||||||
|
- frontend_node_modules:/app/frontend/node_modules
|
||||||
|
- pnpm_store:/pnpm/store
|
||||||
|
- backend_uploads:/app/uploads
|
||||||
|
command: >
|
||||||
|
sh -lc "corepack enable &&
|
||||||
|
corepack prepare pnpm@10.33.2 --activate &&
|
||||||
|
pnpm --filter @pokopia/backend dev"
|
||||||
|
depends_on:
|
||||||
|
deps:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PNPM_HOME: /pnpm
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 20015
|
||||||
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
|
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||||
|
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||||
|
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://localhost:20015}
|
||||||
|
ports:
|
||||||
|
- "20015:20015"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- root_node_modules:/app/node_modules
|
||||||
|
- backend_node_modules:/app/backend/node_modules
|
||||||
|
- frontend_node_modules:/app/frontend/node_modules
|
||||||
|
- pnpm_store:/pnpm/store
|
||||||
|
command: >
|
||||||
|
sh -lc "corepack enable &&
|
||||||
|
corepack prepare pnpm@10.33.2 --activate &&
|
||||||
|
pnpm --filter @pokopia/frontend dev"
|
||||||
|
depends_on:
|
||||||
|
deps:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
backend:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres18_data:
|
||||||
|
backend_uploads:
|
||||||
|
root_node_modules:
|
||||||
|
backend_node_modules:
|
||||||
|
frontend_node_modules:
|
||||||
|
pnpm_store:
|
||||||
@@ -5,10 +5,10 @@ services:
|
|||||||
POSTGRES_DB: pokopia
|
POSTGRES_DB: pokopia
|
||||||
POSTGRES_USER: pokopia
|
POSTGRES_USER: pokopia
|
||||||
POSTGRES_PASSWORD: pokopia
|
POSTGRES_PASSWORD: pokopia
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres18_data:/var/lib/postgresql
|
- postgres18_data:/var/lib/postgresql
|
||||||
|
ports:
|
||||||
|
- "50001:5432" # 添加这一行:宿主机 50001 → 容器 5432
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U pokopia -d pokopia"]
|
test: ["CMD-SHELL", "pg_isready -U pokopia -d pokopia"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -22,12 +22,17 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
|
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
|
||||||
BACKEND_PORT: 3001
|
BACKEND_PORT: 3001
|
||||||
FRONTEND_ORIGIN: http://localhost:3000
|
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||||
APP_ORIGIN: http://localhost:3000
|
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:20015}
|
||||||
|
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:20015}
|
||||||
|
UPLOAD_DIR: /app/uploads
|
||||||
|
BACKEND_PUBLIC_ORIGIN: ${BACKEND_PUBLIC_ORIGIN:-http://localhost:20016}
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
|
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "20016:3001"
|
||||||
|
volumes:
|
||||||
|
- backend_uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -36,12 +41,30 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
|
args:
|
||||||
|
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||||
|
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||||
|
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com}
|
||||||
environment:
|
environment:
|
||||||
VITE_API_BASE_URL: http://localhost:3001
|
PORT: 20015
|
||||||
ports:
|
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||||
- "3000:3000"
|
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||||
|
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com}
|
||||||
|
expose:
|
||||||
|
- "20015"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
frontend_gateway:
|
||||||
|
image: nginx:1.29-alpine
|
||||||
|
ports:
|
||||||
|
- "20015:20015"
|
||||||
|
volumes:
|
||||||
|
- ./frontend/gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./frontend/gateway/maintenance.html:/usr/share/nginx/html/maintenance.html:ro
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres18_data:
|
postgres18_data:
|
||||||
|
backend_uploads:
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY backend/package.json ./backend/package.json
|
||||||
|
COPY frontend/package.json ./frontend/package.json
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install --frozen-lockfile --filter @pokopia/frontend...
|
||||||
|
COPY frontend ./frontend
|
||||||
|
COPY system-wordings.ts ./system-wordings.ts
|
||||||
|
|
||||||
|
ARG NUXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
||||||
|
ARG NUXT_SERVER_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_SERVER_API_BASE_URL=$NUXT_SERVER_API_BASE_URL
|
||||||
|
ENV NUXT_PUBLIC_SITE_URL=$NUXT_PUBLIC_SITE_URL
|
||||||
|
RUN pnpm --filter @pokopia/frontend build
|
||||||
|
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
ENV NODE_ENV=production
|
||||||
COPY frontend/package.json ./
|
ENV HOST=0.0.0.0
|
||||||
RUN corepack enable && pnpm install
|
ENV PORT=20015
|
||||||
COPY frontend/. .
|
|
||||||
COPY package.json /app/package.json
|
WORKDIR /app
|
||||||
COPY system-wordings.ts /app/system-wordings.ts
|
COPY --from=build /app/frontend/.output ./.output
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["pnpm", "run", "dev"]
|
USER node
|
||||||
|
EXPOSE 20015
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
|
|||||||
205
frontend/app.vue
Normal file
205
frontend/app.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import AppShell from './src/components/AppShell.vue';
|
||||||
|
import {
|
||||||
|
iconAction,
|
||||||
|
iconAdmin,
|
||||||
|
iconArtifact,
|
||||||
|
iconAutomation,
|
||||||
|
iconChecklist,
|
||||||
|
iconClothes,
|
||||||
|
iconDish,
|
||||||
|
iconDreamIsland,
|
||||||
|
iconEvent,
|
||||||
|
iconHabitat,
|
||||||
|
iconHome,
|
||||||
|
iconItem,
|
||||||
|
iconLife,
|
||||||
|
iconPokemon,
|
||||||
|
iconRecipe,
|
||||||
|
iconThreads,
|
||||||
|
type AppIcon
|
||||||
|
} from './src/icons';
|
||||||
|
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
|
||||||
|
import { api, notifyAuthChange, onAuthChange, type AuthUser, type Language } from './src/services/api';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
|
const viewAsBusy = ref(false);
|
||||||
|
const languages = ref<Language[]>([
|
||||||
|
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
|
||||||
|
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
|
||||||
|
]);
|
||||||
|
let removeAuthListener: (() => void) | null = null;
|
||||||
|
let removeLocaleListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
type NavBadge = {
|
||||||
|
label: string;
|
||||||
|
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavLinkItem = {
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
icon?: AppIcon;
|
||||||
|
badge?: NavBadge;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavGroupItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: AppIcon;
|
||||||
|
children: NavLinkItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavItem = NavLinkItem | NavGroupItem;
|
||||||
|
|
||||||
|
function inDevBadge(): NavBadge {
|
||||||
|
return { label: t('common.inDev'), tone: 'info' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function can(permissionKey: string) {
|
||||||
|
return currentUser.value?.permissions.includes(permissionKey) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = computed<NavItem[]>(() => {
|
||||||
|
const items: NavItem[] = [
|
||||||
|
{ label: t('nav.home'), to: '/', icon: iconHome },
|
||||||
|
{
|
||||||
|
key: 'pokedex',
|
||||||
|
label: t('nav.pokedex'),
|
||||||
|
icon: iconPokemon,
|
||||||
|
children: [
|
||||||
|
{ label: t('nav.mainGame'), to: '/pokemon', icon: iconPokemon },
|
||||||
|
{ label: t('nav.event'), to: '/event-pokemon', icon: iconEvent }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'habitat-dex',
|
||||||
|
label: t('nav.habitatDex'),
|
||||||
|
icon: iconHabitat,
|
||||||
|
children: [
|
||||||
|
{ label: t('nav.mainGame'), to: '/habitats', icon: iconHabitat },
|
||||||
|
{ label: t('nav.event'), to: '/event-habitats', icon: iconEvent }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'collections',
|
||||||
|
label: t('nav.collections'),
|
||||||
|
icon: iconItem,
|
||||||
|
children: [
|
||||||
|
{ label: t('nav.mainGame'), to: '/items', icon: iconItem },
|
||||||
|
{ label: t('nav.event'), to: '/event-items', icon: iconEvent },
|
||||||
|
{ label: t('nav.ancientArtifacts'), to: '/ancient-artifacts', icon: iconArtifact }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
|
||||||
|
{ label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.dish'), to: '/dish', icon: iconDish },
|
||||||
|
{ label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
||||||
|
{ label: t('nav.life'), to: '/life', icon: iconLife },
|
||||||
|
{ label: t('nav.threads'), to: '/threads', icon: iconThreads }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (can('admin.access')) {
|
||||||
|
items.push({ label: t('nav.admin'), to: '/admin', icon: iconAdmin });
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCurrentUser() {
|
||||||
|
try {
|
||||||
|
const response = await api.me();
|
||||||
|
currentUser.value = response.user;
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await api.logout();
|
||||||
|
} catch {
|
||||||
|
// The local session is cleared even when the server session is already gone.
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser.value = null;
|
||||||
|
notifyAuthChange();
|
||||||
|
await router.push('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopViewAs() {
|
||||||
|
if (viewAsBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewAsBusy.value = true;
|
||||||
|
try {
|
||||||
|
const response = await api.stopViewAs();
|
||||||
|
currentUser.value = response.user;
|
||||||
|
notifyAuthChange();
|
||||||
|
} finally {
|
||||||
|
viewAsBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLanguages() {
|
||||||
|
try {
|
||||||
|
const loadedLanguages = await api.languages();
|
||||||
|
if (loadedLanguages.length) {
|
||||||
|
languages.value = loadedLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!languages.value.some((language) => language.code === getCurrentLocale() && language.enabled)) {
|
||||||
|
setCurrentLocale('en');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadSystemWordings(getCurrentLocale());
|
||||||
|
} catch {
|
||||||
|
// Keep the built-in language list when the API is not ready yet.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLocale(value: string) {
|
||||||
|
await loadSystemWordings(value);
|
||||||
|
setCurrentLocale(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadLanguages();
|
||||||
|
void loadCurrentUser();
|
||||||
|
removeAuthListener = onAuthChange(() => {
|
||||||
|
void loadCurrentUser();
|
||||||
|
});
|
||||||
|
removeLocaleListener = onLocaleChange(() => {
|
||||||
|
void loadLanguages();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
removeAuthListener?.();
|
||||||
|
removeLocaleListener?.();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppShell
|
||||||
|
:current-user="currentUser"
|
||||||
|
:languages="languages"
|
||||||
|
:locale="locale"
|
||||||
|
:nav-items="navItems"
|
||||||
|
:view-as-busy="viewAsBusy"
|
||||||
|
@logout="logout"
|
||||||
|
@stop-view-as="stopViewAs"
|
||||||
|
@update:locale="updateLocale"
|
||||||
|
>
|
||||||
|
<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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
224
frontend/gateway/maintenance.html
Normal file
224
frontend/gateway/maintenance.html
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<meta http-equiv="refresh" content="30" />
|
||||||
|
<title>Pokopia Wiki is upgrading</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--pokemon-yellow: #ffcb05;
|
||||||
|
--pokemon-yellow-soft: #ffe46b;
|
||||||
|
--pokemon-blue: #2a75bb;
|
||||||
|
--pokemon-blue-deep: #003a70;
|
||||||
|
--pokemon-red: #ee1515;
|
||||||
|
--pokemon-red-deep: #cc0000;
|
||||||
|
--bg: #f2f5fa;
|
||||||
|
--bg-alt: #eaf1fb;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #f8fafd;
|
||||||
|
--ink: #151923;
|
||||||
|
--ink-soft: #354052;
|
||||||
|
--muted: #687487;
|
||||||
|
--line: #d8deea;
|
||||||
|
--line-strong: #1f2a3b;
|
||||||
|
--shadow-raised: 0 14px 32px rgba(23, 35, 54, .13);
|
||||||
|
--font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--font-display: "Arial Rounded MT Bold", "Nunito", "Avenir Next Rounded", var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(42, 117, 187, .08) 1px, transparent 1px) 0 0 / 32px 32px,
|
||||||
|
linear-gradient(rgba(42, 117, 187, .08) 1px, transparent 1px) 0 0 / 32px 32px,
|
||||||
|
linear-gradient(180deg, var(--bg) 0%, var(--bg-alt) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-card {
|
||||||
|
width: min(100%, 560px);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(31, 42, 59, .14);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ribbon {
|
||||||
|
height: 12px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, var(--pokemon-red) 0 28%, var(--line-strong) 28% 34%, var(--surface) 34% 66%, var(--line-strong) 66% 72%, var(--pokemon-blue) 72% 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: clamp(28px, 6vw, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 4px solid var(--line-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, var(--pokemon-red) 0 45%, var(--line-strong) 45% 55%, var(--surface) 55% 100%);
|
||||||
|
box-shadow: 0 4px 0 rgba(31, 42, 59, .2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 13px;
|
||||||
|
border: 4px solid var(--line-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
display: block;
|
||||||
|
color: var(--pokemon-yellow);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: .95;
|
||||||
|
-webkit-text-stroke: 2px var(--pokemon-blue-deep);
|
||||||
|
text-shadow: 2px 3px 0 var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--pokemon-blue) 28%, var(--line));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface));
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-size: .82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 20px 0 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
max-width: 38rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 1.12rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter {
|
||||||
|
height: 12px;
|
||||||
|
margin-top: 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter span {
|
||||||
|
display: block;
|
||||||
|
width: 70%;
|
||||||
|
height: 100%;
|
||||||
|
border-right: 1px solid rgba(31, 42, 59, .28);
|
||||||
|
background: linear-gradient(90deg, var(--pokemon-yellow) 0%, var(--pokemon-yellow-soft) 46%, var(--pokemon-blue) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
main {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-card {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 1.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main aria-labelledby="maintenance-title">
|
||||||
|
<section class="maintenance-card" aria-live="polite">
|
||||||
|
<div class="status-ribbon" aria-hidden="true"></div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="mark" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<span class="brand-name">Pokopia</span>
|
||||||
|
<span class="brand-subtitle">Wiki</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="status">Upgrading</span>
|
||||||
|
<h1 id="maintenance-title">Pokopia Wiki is upgrading</h1>
|
||||||
|
<p>We'll be online within 5 minutes.</p>
|
||||||
|
<div class="meter" aria-hidden="true"><span></span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
frontend/gateway/nginx.conf
Normal file
45
frontend/gateway/nginx.conf
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
server {
|
||||||
|
listen 20015;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=5s ipv6=off;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
auth_request /backend-health;
|
||||||
|
error_page 500 502 503 504 =503 /maintenance.html;
|
||||||
|
|
||||||
|
set $frontend_upstream http://frontend:20015;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_connect_timeout 1s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
proxy_intercept_errors on;
|
||||||
|
|
||||||
|
proxy_pass $frontend_upstream;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /backend-health {
|
||||||
|
internal;
|
||||||
|
set $backend_upstream http://backend:3001/health;
|
||||||
|
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_connect_timeout 1s;
|
||||||
|
proxy_read_timeout 1s;
|
||||||
|
|
||||||
|
proxy_pass $backend_upstream;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
add_header Retry-After "300" always;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Pokopia Wiki</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
35
frontend/middleware/auth.global.ts
Normal file
35
frontend/middleware/auth.global.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { api } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.me(import.meta.server ? { headers: useRequestHeaders(['cookie']) } : undefined);
|
||||||
|
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 {
|
||||||
|
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
||||||
|
}
|
||||||
|
});
|
||||||
50
frontend/nuxt.config.ts
Normal file
50
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||||
|
|
||||||
|
function normalizeSiteUrl(value: string | undefined): string {
|
||||||
|
return (value?.trim() || fallbackSiteUrl).replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
ssr: true,
|
||||||
|
devtools: { enabled: false },
|
||||||
|
css: ['~/src/styles/main.css'],
|
||||||
|
compatibilityDate: '2026-05-06',
|
||||||
|
runtimeConfig: {
|
||||||
|
serverApiBaseUrl:
|
||||||
|
process.env.NUXT_SERVER_API_BASE_URL ??
|
||||||
|
process.env.NUXT_PUBLIC_API_BASE_URL ??
|
||||||
|
'http://localhost:3001',
|
||||||
|
public: {
|
||||||
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3001',
|
||||||
|
siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_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: 'theme-color', content: '#6ccf32' }
|
||||||
|
],
|
||||||
|
link: [
|
||||||
|
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' }
|
||||||
|
],
|
||||||
|
script: [
|
||||||
|
{
|
||||||
|
async: true,
|
||||||
|
src: 'https://umami.tootaio.com/script.js',
|
||||||
|
'data-website-id': '6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
prerender: {
|
||||||
|
routes: ['/robots.txt', '/sitemap.xml']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,25 +5,25 @@
|
|||||||
"packageManager": "pnpm@10.33.2",
|
"packageManager": "pnpm@10.33.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
"dev": "nuxt dev --host 0.0.0.0 --port 20015",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "nuxt build",
|
||||||
"lint": "vue-tsc --noEmit",
|
"lint": "nuxt typecheck",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "nuxt typecheck",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify/vue": "^5.0.0",
|
"@iconify/vue": "5.0.0",
|
||||||
"@vitejs/plugin-vue": "latest",
|
"nuxt": "4.4.4",
|
||||||
"vite": "latest",
|
"vue": "3.5.33",
|
||||||
"vue": "latest",
|
"vue-i18n": "11.4.0",
|
||||||
"vue-i18n": "^11.4.0",
|
"vue-router": "5.0.6"
|
||||||
"vue-router": "latest"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "latest",
|
"@types/node": "25.6.0",
|
||||||
"@vue/tsconfig": "latest",
|
"@vue/tsconfig": "0.9.1",
|
||||||
"typescript": "latest",
|
"postcss": "8.5.13",
|
||||||
"vitest": "latest",
|
"typescript": "6.0.3",
|
||||||
"vue-tsc": "latest"
|
"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>
|
||||||
17
frontend/pages/threads/[id].vue
Normal file
17
frontend/pages/threads/[id].vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import ThreadsView from '../../src/views/ThreadsView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'thread-detail',
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.threads.title',
|
||||||
|
descriptionKey: 'seo.threadsDescription',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/threads/${String(route.params.id)}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ThreadsView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/threads/index.vue
Normal file
12
frontend/pages/threads/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ThreadsView from '../../src/views/ThreadsView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'threads',
|
||||||
|
seo: { titleKey: 'pages.threads.title', descriptionKey: 'seo.threadsDescription', canonicalPath: '/threads' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ThreadsView />
|
||||||
|
</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>
|
||||||
15
frontend/plugins/00-runtime-config.ts
Normal file
15
frontend/plugins/00-runtime-config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { setSystemWordingsApiBaseUrls } from '../src/i18n';
|
||||||
|
import { setConfiguredSiteUrl } from '../src/seo';
|
||||||
|
import { setApiBaseUrls } from '../src/services/api';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBaseUrls = {
|
||||||
|
browser: config.public.apiBaseUrl,
|
||||||
|
server: config.serverApiBaseUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
setApiBaseUrls(apiBaseUrls);
|
||||||
|
setSystemWordingsApiBaseUrls(apiBaseUrls);
|
||||||
|
setConfiguredSiteUrl(config.public.siteUrl);
|
||||||
|
});
|
||||||
15
frontend/plugins/01-i18n.ts
Normal file
15
frontend/plugins/01-i18n.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createPokopiaI18n, setActiveI18n } from '../src/i18n';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
const i18n = createPokopiaI18n();
|
||||||
|
if (import.meta.client) {
|
||||||
|
setActiveI18n(i18n);
|
||||||
|
}
|
||||||
|
|
||||||
|
nuxtApp.vueApp.use(i18n);
|
||||||
|
return {
|
||||||
|
provide: {
|
||||||
|
pokopiaI18n: i18n
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
32
frontend/plugins/02-seo.ts
Normal file
32
frontend/plugins/02-seo.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { onLocaleChange } from '../src/i18n';
|
||||||
|
import { applyRouteSeo, onSeoChange, resolvedSeoHead, resolveRouteSeo, setSeoTranslator, type ResolvedSeoConfig } from '../src/seo';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const router = useRouter();
|
||||||
|
const nuxtApp = useNuxtApp();
|
||||||
|
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
|
||||||
|
const dynamicSeo = ref<ResolvedSeoConfig | null>(null);
|
||||||
|
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t));
|
||||||
|
useHead(() => resolvedSeoHead(activeSeo.value));
|
||||||
|
|
||||||
|
if (import.meta.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSeoTranslator(t);
|
||||||
|
onSeoChange((seo) => {
|
||||||
|
dynamicSeo.value = seo;
|
||||||
|
});
|
||||||
|
onLocaleChange(() => {
|
||||||
|
dynamicSeo.value = null;
|
||||||
|
applyRouteSeo(router.currentRoute.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
dynamicSeo.value = null;
|
||||||
|
applyRouteSeo(to);
|
||||||
|
});
|
||||||
|
|
||||||
|
applyRouteSeo(router.currentRoute.value);
|
||||||
|
});
|
||||||
81
frontend/plugins/03-detail-seo.server.ts
Normal file
81
frontend/plugins/03-detail-seo.server.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { resolvedSeoHead, resolveSeo, threadSeoConfig, type SeoConfig } from '../src/seo';
|
||||||
|
import { api } from '../src/services/api';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(async () => {
|
||||||
|
const route = useRoute();
|
||||||
|
const routeId = typeof route.params.id === 'string' && route.params.id.trim() !== '' ? route.params.id : null;
|
||||||
|
if (!routeId || typeof route.name !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nuxtApp = useNuxtApp();
|
||||||
|
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
|
||||||
|
const seo = await detailSeo(String(route.name), routeId, t);
|
||||||
|
if (seo) {
|
||||||
|
useHead(resolvedSeoHead(resolveSeo(seo)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function detailSeo(
|
||||||
|
routeName: string,
|
||||||
|
routeId: string,
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => string
|
||||||
|
): Promise<SeoConfig | null> {
|
||||||
|
try {
|
||||||
|
if (routeName === 'pokemon-detail') {
|
||||||
|
const pokemon = await api.pokemonDetail(routeId);
|
||||||
|
return {
|
||||||
|
title: `${pokemon.name} - ${t(pokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`,
|
||||||
|
description: t('seo.pokemonDetailDescription', { name: pokemon.name }),
|
||||||
|
canonicalPath: `/pokemon/${pokemon.id}`,
|
||||||
|
image: pokemon.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeName === 'habitat-detail') {
|
||||||
|
const habitat = await api.habitatDetail(routeId);
|
||||||
|
return {
|
||||||
|
title: `${habitat.name} - ${t(habitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
|
||||||
|
description: t('seo.habitatDetailDescription', { name: habitat.name }),
|
||||||
|
canonicalPath: `/habitats/${habitat.id}`,
|
||||||
|
image: habitat.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeName === 'item-detail' || routeName === 'ancient-artifact-detail') {
|
||||||
|
const item = await api.itemDetail(routeId);
|
||||||
|
const ancientArtifactRoute = routeName === 'ancient-artifact-detail';
|
||||||
|
if (ancientArtifactRoute && !item.ancientArtifactCategory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleKey = ancientArtifactRoute ? 'pages.ancientArtifacts.title' : item.isEventItem ? 'pages.eventItems.title' : 'pages.items.title';
|
||||||
|
const descriptionKey = ancientArtifactRoute ? 'seo.ancientArtifactDetailDescription' : 'seo.itemDetailDescription';
|
||||||
|
return {
|
||||||
|
title: `${item.name} - ${t(titleKey)}`,
|
||||||
|
description: t(descriptionKey, { name: item.name }),
|
||||||
|
canonicalPath: ancientArtifactRoute ? `/ancient-artifacts/${item.id}` : `/items/${item.id}`,
|
||||||
|
image: item.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeName === 'recipe-detail') {
|
||||||
|
const recipe = await api.recipeDetail(routeId);
|
||||||
|
return {
|
||||||
|
title: `${recipe.name} - ${t('pages.recipes.title')}`,
|
||||||
|
description: t('seo.recipeDetailDescription', { name: recipe.name }),
|
||||||
|
canonicalPath: `/recipes/${recipe.id}`,
|
||||||
|
image: recipe.item.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeName === 'thread-detail') {
|
||||||
|
const thread = await api.thread(routeId);
|
||||||
|
return threadSeoConfig(thread, t);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/public/seo/pokopia-hero.jpg
Normal file
BIN
frontend/public/seo/pokopia-hero.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 736 KiB |
BIN
frontend/public/seo/pokopia-logo.png
Normal file
BIN
frontend/public/seo/pokopia-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
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-collections.xml.ts
Normal file
7
frontend/server/routes/sitemap-collections.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { collectionsSitemapXml, normalizeApiBaseUrl, normalizeSiteUrl } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return collectionsSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-habitats.xml.ts
Normal file
7
frontend/server/routes/sitemap-habitats.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { habitatsSitemapXml, normalizeApiBaseUrl, normalizeSiteUrl } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return habitatsSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-life.xml.ts
Normal file
7
frontend/server/routes/sitemap-life.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { lifeSitemapXml, normalizeApiBaseUrl, normalizeSiteUrl } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return lifeSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-pokedex.xml.ts
Normal file
7
frontend/server/routes/sitemap-pokedex.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeApiBaseUrl, normalizeSiteUrl, pokedexSitemapXml } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return pokedexSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-static.xml.ts
Normal file
7
frontend/server/routes/sitemap-static.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeSiteUrl, staticSitemapXml } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return staticSitemapXml(normalizeSiteUrl(config.public.siteUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-threads.xml.ts
Normal file
7
frontend/server/routes/sitemap-threads.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeApiBaseUrl, normalizeSiteUrl, threadsSitemapXml } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return threadsSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap.xml.ts
Normal file
7
frontend/server/routes/sitemap.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeSiteUrl, sitemapIndexXml } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return sitemapIndexXml(normalizeSiteUrl(config.public.siteUrl));
|
||||||
|
});
|
||||||
273
frontend/server/utils/seo-files.ts
Normal file
273
frontend/server/utils/seo-files.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||||
|
const fallbackApiBaseUrl = 'http://localhost:3001';
|
||||||
|
const staticLastmod = new Date().toISOString();
|
||||||
|
const sitemapPageSize = 72;
|
||||||
|
|
||||||
|
type ChangeFrequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||||
|
|
||||||
|
export type SitemapUrl = {
|
||||||
|
path: string;
|
||||||
|
lastmod?: string | null;
|
||||||
|
changefreq?: ChangeFrequency;
|
||||||
|
priority?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SitemapEntity = {
|
||||||
|
id: number;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
lastActiveAt?: string | null;
|
||||||
|
ancientArtifactCategory?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListPage<T> = {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sitemapFiles = [
|
||||||
|
'/sitemap-static.xml',
|
||||||
|
'/sitemap-pokedex.xml',
|
||||||
|
'/sitemap-habitats.xml',
|
||||||
|
'/sitemap-collections.xml',
|
||||||
|
'/sitemap-life.xml',
|
||||||
|
'/sitemap-threads.xml'
|
||||||
|
];
|
||||||
|
|
||||||
|
const staticSitemapUrls: SitemapUrl[] = [
|
||||||
|
{ path: '/', changefreq: 'weekly', priority: 1 },
|
||||||
|
{ path: '/pokemon', changefreq: 'weekly', priority: 0.95 },
|
||||||
|
{ path: '/event-pokemon', changefreq: 'weekly', priority: 0.85 },
|
||||||
|
{ path: '/habitats', changefreq: 'weekly', priority: 0.9 },
|
||||||
|
{ path: '/event-habitats', changefreq: 'weekly', priority: 0.8 },
|
||||||
|
{ path: '/items', changefreq: 'weekly', priority: 0.9 },
|
||||||
|
{ path: '/event-items', changefreq: 'weekly', priority: 0.8 },
|
||||||
|
{ path: '/ancient-artifacts', changefreq: 'weekly', priority: 0.85 },
|
||||||
|
{ path: '/recipes', changefreq: 'weekly', priority: 0.85 },
|
||||||
|
{ path: '/dish', changefreq: 'weekly', priority: 0.8 },
|
||||||
|
{ path: '/checklist', changefreq: 'weekly', priority: 0.8 },
|
||||||
|
{ path: '/life', changefreq: 'daily', priority: 0.75 },
|
||||||
|
{ path: '/threads', changefreq: 'daily', priority: 0.75 },
|
||||||
|
{ path: '/project-updates', changefreq: 'weekly', priority: 0.6 },
|
||||||
|
{ path: '/privacy-policy', changefreq: 'yearly', priority: 0.3 },
|
||||||
|
{ path: '/terms-of-service', changefreq: 'yearly', priority: 0.3 },
|
||||||
|
{ path: '/disclaimers', changefreq: 'yearly', priority: 0.3 }
|
||||||
|
];
|
||||||
|
|
||||||
|
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 normalizeApiBaseUrl(value: unknown): string {
|
||||||
|
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackApiBaseUrl).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 sitemapIndexXml(siteUrl: string): string {
|
||||||
|
const sitemaps = sitemapFiles
|
||||||
|
.map(
|
||||||
|
(path) => ` <sitemap>
|
||||||
|
<loc>${xmlEscape(siteUrl + path)}</loc>
|
||||||
|
<lastmod>${formatLastmod(staticLastmod)}</lastmod>
|
||||||
|
</sitemap>`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${sitemaps}
|
||||||
|
</sitemapindex>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function staticSitemapXml(siteUrl: string): string {
|
||||||
|
return sitemapXml(
|
||||||
|
siteUrl,
|
||||||
|
staticSitemapUrls.map((url) => ({ ...url, lastmod: staticLastmod }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pokedexSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
|
||||||
|
const pokemon = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/pokemon');
|
||||||
|
return sitemapXml(
|
||||||
|
siteUrl,
|
||||||
|
pokemon.map((item) => ({
|
||||||
|
path: `/pokemon/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'weekly',
|
||||||
|
priority: 0.8
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function habitatsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
|
||||||
|
const habitats = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/habitats');
|
||||||
|
return sitemapXml(
|
||||||
|
siteUrl,
|
||||||
|
habitats.map((item) => ({
|
||||||
|
path: `/habitats/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'weekly',
|
||||||
|
priority: 0.75
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectionsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
|
||||||
|
const [items, artifacts, recipes] = await Promise.all([
|
||||||
|
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/items'),
|
||||||
|
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/ancient-artifacts'),
|
||||||
|
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/recipes')
|
||||||
|
]);
|
||||||
|
return sitemapXml(siteUrl, [
|
||||||
|
...items
|
||||||
|
.filter((item) => !item.ancientArtifactCategory)
|
||||||
|
.map((item) => ({
|
||||||
|
path: `/items/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'weekly' as const,
|
||||||
|
priority: 0.75
|
||||||
|
})),
|
||||||
|
...artifacts.map((item) => ({
|
||||||
|
path: `/ancient-artifacts/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'weekly' as const,
|
||||||
|
priority: 0.75
|
||||||
|
})),
|
||||||
|
...recipes.map((item) => ({
|
||||||
|
path: `/recipes/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'weekly' as const,
|
||||||
|
priority: 0.7
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lifeSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
|
||||||
|
const posts = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/life-posts');
|
||||||
|
return sitemapXml(
|
||||||
|
siteUrl,
|
||||||
|
posts.map((item) => ({
|
||||||
|
path: `/life/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'daily',
|
||||||
|
priority: 0.65
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function threadsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
|
||||||
|
const threads = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/threads');
|
||||||
|
return sitemapXml(
|
||||||
|
siteUrl,
|
||||||
|
threads.map((item) => ({
|
||||||
|
path: `/threads/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'daily',
|
||||||
|
priority: 0.65
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sitemapXml(siteUrl: string, urls: SitemapUrl[]): string {
|
||||||
|
const body = urls.map((url) => sitemapUrlXml(siteUrl, url)).join('\n');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${body}
|
||||||
|
</urlset>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sitemapUrlXml(siteUrl: string, url: SitemapUrl): string {
|
||||||
|
return [
|
||||||
|
' <url>',
|
||||||
|
` <loc>${xmlEscape(siteUrl + normalizePath(url.path))}</loc>`,
|
||||||
|
...(url.lastmod ? [` <lastmod>${formatLastmod(url.lastmod)}</lastmod>`] : []),
|
||||||
|
...(url.changefreq ? [` <changefreq>${url.changefreq}</changefreq>`] : []),
|
||||||
|
...(url.priority !== undefined ? [` <priority>${formatPriority(url.priority)}</priority>`] : []),
|
||||||
|
' </url>'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllPages<T extends SitemapEntity>(apiBaseUrl: string, path: string): Promise<T[]> {
|
||||||
|
const items: T[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const url = new URL(path, `${apiBaseUrl}/`);
|
||||||
|
url.searchParams.set('limit', String(sitemapPageSize));
|
||||||
|
if (cursor) {
|
||||||
|
url.searchParams.set('cursor', cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Sitemap source request failed: ${path} (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = (await response.json()) as ListPage<T>;
|
||||||
|
items.push(...page.items);
|
||||||
|
cursor = page.hasMore ? page.nextCursor : null;
|
||||||
|
} while (cursor);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityLastmod(entity: SitemapEntity): string | null {
|
||||||
|
return entity.lastActiveAt ?? entity.updatedAt ?? entity.createdAt ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
return path.startsWith('/') ? path : `/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastmod(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? xmlEscape(value) : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPriority(value: number): string {
|
||||||
|
return Math.max(0, Math.min(1, value)).toFixed(2).replace(/0$/, '').replace(/\.0$/, '.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlEscape(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<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 {
|
|
||||||
iconAction,
|
|
||||||
iconAdmin,
|
|
||||||
iconChecklist,
|
|
||||||
iconClothes,
|
|
||||||
iconDish,
|
|
||||||
iconDreamIsland,
|
|
||||||
iconEvent,
|
|
||||||
iconHabitat,
|
|
||||||
iconItem,
|
|
||||||
iconLife,
|
|
||||||
iconPokemon,
|
|
||||||
iconRecipe
|
|
||||||
} from './icons';
|
|
||||||
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './i18n';
|
|
||||||
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
|
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
|
||||||
const languages = ref<Language[]>([
|
|
||||||
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
|
|
||||||
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
|
|
||||||
]);
|
|
||||||
let removeAuthListener: (() => void) | null = null;
|
|
||||||
let removeLocaleListener: (() => void) | null = null;
|
|
||||||
|
|
||||||
function inDevBadge() {
|
|
||||||
return { label: t('common.inDev'), tone: 'info' as const };
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems = computed(() => [
|
|
||||||
{ label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon },
|
|
||||||
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
|
|
||||||
{ label: t('nav.items'), to: '/items', icon: iconItem },
|
|
||||||
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
|
|
||||||
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },
|
|
||||||
{ label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() },
|
|
||||||
{ label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
|
|
||||||
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
|
|
||||||
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
|
|
||||||
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
|
||||||
{ label: t('nav.life'), to: '/life', icon: iconLife },
|
|
||||||
{ label: t('nav.admin'), to: '/admin', icon: iconAdmin }
|
|
||||||
]);
|
|
||||||
|
|
||||||
async function loadCurrentUser() {
|
|
||||||
if (!getAuthToken()) {
|
|
||||||
currentUser.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.me();
|
|
||||||
currentUser.value = response.user;
|
|
||||||
} catch {
|
|
||||||
currentUser.value = null;
|
|
||||||
setAuthToken(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
try {
|
|
||||||
await api.logout();
|
|
||||||
} catch {
|
|
||||||
// The local session is cleared even when the server session is already gone.
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUser.value = null;
|
|
||||||
setAuthToken(null);
|
|
||||||
await router.push('/pokemon');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLanguages() {
|
|
||||||
try {
|
|
||||||
const loadedLanguages = await api.languages();
|
|
||||||
if (loadedLanguages.length) {
|
|
||||||
languages.value = loadedLanguages;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!languages.value.some((language) => language.code === getCurrentLocale() && language.enabled)) {
|
|
||||||
setCurrentLocale('en');
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadSystemWordings(getCurrentLocale());
|
|
||||||
} catch {
|
|
||||||
// Keep the built-in language list when the API is not ready yet.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateLocale(value: string) {
|
|
||||||
await loadSystemWordings(value);
|
|
||||||
setCurrentLocale(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadLanguages();
|
|
||||||
void loadCurrentUser();
|
|
||||||
removeAuthListener = onAuthTokenChange(() => {
|
|
||||||
void loadCurrentUser();
|
|
||||||
});
|
|
||||||
removeLocaleListener = onLocaleChange(() => {
|
|
||||||
void loadLanguages();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
removeAuthListener?.();
|
|
||||||
removeLocaleListener?.();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AppShell
|
|
||||||
:current-user="currentUser"
|
|
||||||
:languages="languages"
|
|
||||||
:locale="locale"
|
|
||||||
:nav-items="navItems"
|
|
||||||
@logout="logout"
|
|
||||||
@update:locale="updateLocale"
|
|
||||||
>
|
|
||||||
<RouterView :key="locale" />
|
|
||||||
</AppShell>
|
|
||||||
</template>
|
|
||||||
@@ -3,54 +3,126 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { iconClose, iconLogin, iconLogout, iconMenu, iconProfile, iconRegister, iconTranslate, type AppIcon } from '../icons';
|
import {
|
||||||
|
iconChevronDown,
|
||||||
|
iconChevronRight,
|
||||||
|
iconClose,
|
||||||
|
iconLogin,
|
||||||
|
iconLogout,
|
||||||
|
iconMenu,
|
||||||
|
iconProfile,
|
||||||
|
iconRegister,
|
||||||
|
iconTranslate,
|
||||||
|
type AppIcon
|
||||||
|
} from '../icons';
|
||||||
import type { AuthUser, Language } from '../services/api';
|
import type { AuthUser, Language } from '../services/api';
|
||||||
|
import GlobalSearch from './GlobalSearch.vue';
|
||||||
|
import NotificationBell from './NotificationBell.vue';
|
||||||
import PokeBallMark from './PokeBallMark.vue';
|
import PokeBallMark from './PokeBallMark.vue';
|
||||||
import StatusBadge from './StatusBadge.vue';
|
import StatusBadge from './StatusBadge.vue';
|
||||||
|
import ViewAsBanner from './ViewAsBanner.vue';
|
||||||
|
|
||||||
|
type NavBadge = {
|
||||||
|
label: string;
|
||||||
|
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavLinkItem = {
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
icon?: AppIcon;
|
||||||
|
badge?: NavBadge;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavGroupItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: AppIcon;
|
||||||
|
children: NavLinkItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavItem = NavLinkItem | NavGroupItem;
|
||||||
|
|
||||||
|
type SidebarTooltip = {
|
||||||
|
label: string;
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
currentUser: AuthUser | null;
|
currentUser: AuthUser | null;
|
||||||
languages: Language[];
|
languages: Language[];
|
||||||
locale: string;
|
locale: string;
|
||||||
navItems: Array<{
|
navItems: NavItem[];
|
||||||
label: string;
|
viewAsBusy?: boolean;
|
||||||
to: string;
|
|
||||||
icon?: AppIcon;
|
|
||||||
badge?: {
|
|
||||||
label: string;
|
|
||||||
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
logout: [];
|
logout: [];
|
||||||
|
stopViewAs: [];
|
||||||
'update:locale': [value: string];
|
'update:locale': [value: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const copyrightYear = new Date().getFullYear();
|
||||||
const languageMenu = ref<HTMLElement | null>(null);
|
const languageMenu = ref<HTMLElement | null>(null);
|
||||||
const languageMenuButton = ref<HTMLButtonElement | null>(null);
|
const languageMenuButton = ref<HTMLButtonElement | null>(null);
|
||||||
|
const sideNav = ref<HTMLElement | null>(null);
|
||||||
const languageMenuOpen = ref(false);
|
const languageMenuOpen = ref(false);
|
||||||
const sidebarOpen = ref(false);
|
const sidebarOpen = ref(false);
|
||||||
|
const sidebarCollapsed = ref(false);
|
||||||
|
const expandedNavGroups = ref<Set<string>>(new Set());
|
||||||
|
const sidebarTooltip = ref<SidebarTooltip | null>(null);
|
||||||
|
const sidebarTooltipTarget = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
function closeLanguageMenu() {
|
function closeLanguageMenu() {
|
||||||
languageMenuOpen.value = false;
|
languageMenuOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSidebarTooltipTarget() {
|
||||||
|
sidebarTooltipTarget.value?.removeAttribute('aria-describedby');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSidebarTooltip() {
|
||||||
|
clearSidebarTooltipTarget();
|
||||||
|
sidebarTooltipTarget.value = null;
|
||||||
|
sidebarTooltip.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
function closeSidebar() {
|
function closeSidebar() {
|
||||||
sidebarOpen.value = false;
|
sidebarOpen.value = false;
|
||||||
closeLanguageMenu();
|
closeLanguageMenu();
|
||||||
|
hideSidebarTooltip();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
sidebarOpen.value = !sidebarOpen.value;
|
sidebarOpen.value = !sidebarOpen.value;
|
||||||
closeLanguageMenu();
|
closeLanguageMenu();
|
||||||
|
hideSidebarTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebarCollapsed() {
|
||||||
|
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||||
|
closeLanguageMenu();
|
||||||
|
hideSidebarTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNavGroup(key: string) {
|
||||||
|
const nextGroups = new Set(expandedNavGroups.value);
|
||||||
|
if (nextGroups.has(key)) {
|
||||||
|
nextGroups.delete(key);
|
||||||
|
} else {
|
||||||
|
nextGroups.add(key);
|
||||||
|
}
|
||||||
|
expandedNavGroups.value = nextGroups;
|
||||||
|
closeLanguageMenu();
|
||||||
|
hideSidebarTooltip();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLanguageMenu() {
|
function toggleLanguageMenu() {
|
||||||
languageMenuOpen.value = !languageMenuOpen.value;
|
languageMenuOpen.value = !languageMenuOpen.value;
|
||||||
|
hideSidebarTooltip();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectLocale(value: string) {
|
function selectLocale(value: string) {
|
||||||
@@ -78,27 +150,115 @@ function requestLogout() {
|
|||||||
emit('logout');
|
emit('logout');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestStopViewAs() {
|
||||||
|
emit('stopViewAs');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDesktopSidebar() {
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia('(min-width: 901px)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canShowSidebarTooltip(collapsedOnly = true) {
|
||||||
|
return isDesktopSidebar() && (!collapsedOnly || sidebarCollapsed.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSidebarTooltip(label: string, target: HTMLElement) {
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
clearSidebarTooltipTarget();
|
||||||
|
sidebarTooltipTarget.value = target;
|
||||||
|
target.setAttribute('aria-describedby', 'sidebar-tooltip');
|
||||||
|
sidebarTooltip.value = {
|
||||||
|
label,
|
||||||
|
top: rect.top + rect.height / 2,
|
||||||
|
left: rect.right + 10
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSidebarTooltip(label: string, event: MouseEvent | FocusEvent, collapsedOnly = true) {
|
||||||
|
if (!canShowSidebarTooltip(collapsedOnly) || languageMenuOpen.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.currentTarget;
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
setSidebarTooltip(label, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSidebarTooltipPosition() {
|
||||||
|
const target = sidebarTooltipTarget.value;
|
||||||
|
const currentTooltip = sidebarTooltip.value;
|
||||||
|
if (!target || !currentTooltip || !canShowSidebarTooltip()) {
|
||||||
|
hideSidebarTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sideNav.value?.contains(target)) {
|
||||||
|
const navRect = sideNav.value.getBoundingClientRect();
|
||||||
|
const targetRect = target.getBoundingClientRect();
|
||||||
|
if (targetRect.bottom < navRect.top || targetRect.top > navRect.bottom) {
|
||||||
|
hideSidebarTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSidebarTooltip(currentTooltip.label, target);
|
||||||
|
}
|
||||||
|
|
||||||
function isNavActive(path: string) {
|
function isNavActive(path: string) {
|
||||||
return route.path === path || route.path.startsWith(`${path}/`);
|
return route.path === path || route.path.startsWith(`${path}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNavGroup(item: NavItem): item is NavGroupItem {
|
||||||
|
return 'children' in item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNavGroupActive(item: NavGroupItem) {
|
||||||
|
return item.children.some((child) => isNavActive(child.to));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNavGroupExpanded(item: NavGroupItem) {
|
||||||
|
return expandedNavGroups.value.has(item.key) || isNavGroupActive(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navItemKey(item: NavItem) {
|
||||||
|
return isNavGroup(item) ? item.key : item.to;
|
||||||
|
}
|
||||||
|
|
||||||
watch(sidebarOpen, (open) => {
|
watch(sidebarOpen, (open) => {
|
||||||
document.body.classList.toggle('lock-scroll', open);
|
document.body.classList.toggle('lock-scroll', open);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(sidebarCollapsed, (collapsed) => {
|
||||||
|
if (!collapsed) {
|
||||||
|
hideSidebarTooltip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||||
|
window.addEventListener('resize', updateSidebarTooltipPosition);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||||
|
window.removeEventListener('resize', updateSidebarTooltipPosition);
|
||||||
document.body.classList.remove('lock-scroll');
|
document.body.classList.remove('lock-scroll');
|
||||||
|
hideSidebarTooltip();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-shell" :class="{ 'app-shell--sidebar-open': sidebarOpen }">
|
<div
|
||||||
<header class="mobile-topbar">
|
class="app-shell"
|
||||||
|
:class="{
|
||||||
|
'app-shell--sidebar-open': sidebarOpen,
|
||||||
|
'app-shell--sidebar-collapsed': sidebarCollapsed
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<header class="site-topbar">
|
||||||
|
<div class="site-topbar__inner">
|
||||||
|
<div class="site-topbar__brand">
|
||||||
<button
|
<button
|
||||||
class="sidebar-toggle"
|
class="sidebar-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -110,49 +270,20 @@ onBeforeUnmount(() => {
|
|||||||
<Icon :icon="sidebarOpen ? iconClose : iconMenu" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="sidebarOpen ? iconClose : iconMenu" class="ui-icon" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<RouterLink class="brand-lockup brand-lockup--mobile" to="/pokemon" aria-label="Pokopia Wiki" @click="closeSidebar">
|
<RouterLink class="brand-lockup brand-lockup--topbar" to="/" aria-label="Pokopia Wiki" @click="closeSidebar">
|
||||||
<PokeBallMark size="34px" />
|
<PokeBallMark size="34px" />
|
||||||
<span>
|
<span>
|
||||||
<span class="pokemon-word">Pokopia</span>
|
<span class="pokemon-word">Pokopia</span>
|
||||||
<span class="brand-subtitle">Community Wiki</span>
|
<span class="brand-subtitle">Community Wiki</span>
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<button class="site-sidebar-scrim" type="button" :aria-label="t('nav.closeMenu')" @click="closeSidebar"></button>
|
<GlobalSearch class="site-topbar__search" @navigate="closeSidebar" />
|
||||||
|
|
||||||
<aside id="app-sidebar" class="site-sidebar" :aria-label="t('nav.main')">
|
<div class="site-topbar__spacer" aria-hidden="true"></div>
|
||||||
<div class="site-sidebar__inner">
|
|
||||||
<RouterLink class="brand-lockup" to="/pokemon" aria-label="Pokopia Wiki" @click="closeSidebar">
|
|
||||||
<PokeBallMark size="42px" />
|
|
||||||
<span>
|
|
||||||
<span class="pokemon-word">Pokopia</span>
|
|
||||||
<span class="brand-subtitle">Community Wiki</span>
|
|
||||||
</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<nav class="side-nav" :aria-label="t('nav.main')">
|
<div class="topbar-actions">
|
||||||
<RouterLink
|
|
||||||
v-for="item in navItems"
|
|
||||||
:key="item.to"
|
|
||||||
class="side-nav__link"
|
|
||||||
:class="{ 'router-link-active': isNavActive(item.to) }"
|
|
||||||
:to="item.to"
|
|
||||||
@click="closeSidebar"
|
|
||||||
>
|
|
||||||
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
|
|
||||||
<span class="side-nav__label">{{ item.label }}</span>
|
|
||||||
<StatusBadge
|
|
||||||
v-if="item.badge"
|
|
||||||
class="side-nav__badge"
|
|
||||||
:label="item.badge.label"
|
|
||||||
:tone="item.badge.tone"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</RouterLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="auth-actions">
|
|
||||||
<div ref="languageMenu" class="language-menu" @keydown="onLanguageMenuKeydown">
|
<div ref="languageMenu" class="language-menu" @keydown="onLanguageMenuKeydown">
|
||||||
<button
|
<button
|
||||||
ref="languageMenuButton"
|
ref="languageMenuButton"
|
||||||
@@ -164,7 +295,6 @@ onBeforeUnmount(() => {
|
|||||||
@click="toggleLanguageMenu"
|
@click="toggleLanguageMenu"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconTranslate" class="language-menu__icon" aria-hidden="true" />
|
<Icon :icon="iconTranslate" class="language-menu__icon" aria-hidden="true" />
|
||||||
<span class="language-menu__glyph" aria-hidden="true">文/A</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="languageMenuOpen" class="language-menu__dropdown" role="menu">
|
<div v-if="languageMenuOpen" class="language-menu__dropdown" role="menu">
|
||||||
@@ -183,32 +313,190 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="currentUser">
|
<template v-if="currentUser">
|
||||||
|
<NotificationBell :current-user="currentUser" />
|
||||||
<RouterLink class="auth-user" to="/profile" :aria-label="t('nav.profile')" @click="closeSidebar">
|
<RouterLink class="auth-user" to="/profile" :aria-label="t('nav.profile')" @click="closeSidebar">
|
||||||
<Icon :icon="iconProfile" class="ui-icon auth-user__icon" aria-hidden="true" />
|
<Icon :icon="iconProfile" class="ui-icon auth-user__icon" aria-hidden="true" />
|
||||||
<span class="auth-user__name">{{ currentUser.displayName || currentUser.email }}</span>
|
<span class="auth-user__name">{{ currentUser.displayName || currentUser.email }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="requestLogout">
|
<button
|
||||||
|
class="ui-button ui-button--ghost ui-button--small topbar-actions__icon-button"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('nav.logout')"
|
||||||
|
@click="requestLogout"
|
||||||
|
>
|
||||||
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('nav.logout') }}
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login" @click="closeSidebar">
|
<RouterLink
|
||||||
|
class="ui-button ui-button--ghost ui-button--small topbar-actions__icon-button"
|
||||||
|
to="/login"
|
||||||
|
:aria-label="t('nav.login')"
|
||||||
|
@click="closeSidebar"
|
||||||
|
>
|
||||||
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('nav.login') }}
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/register" @click="closeSidebar">
|
<RouterLink
|
||||||
|
class="ui-button ui-button--primary ui-button--small topbar-actions__icon-button"
|
||||||
|
to="/register"
|
||||||
|
:aria-label="t('nav.register')"
|
||||||
|
@click="closeSidebar"
|
||||||
|
>
|
||||||
<Icon :icon="iconRegister" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconRegister" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('nav.register') }}
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ViewAsBanner
|
||||||
|
v-if="currentUser?.viewAs"
|
||||||
|
:view-as="currentUser.viewAs"
|
||||||
|
:busy="viewAsBusy"
|
||||||
|
@stop="requestStopViewAs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button class="site-sidebar-scrim" type="button" :aria-label="t('nav.closeMenu')" @click="closeSidebar"></button>
|
||||||
|
|
||||||
|
<aside id="app-sidebar" class="site-sidebar" :aria-label="t('nav.main')">
|
||||||
|
<div class="site-sidebar__inner">
|
||||||
|
<div class="site-sidebar__header">
|
||||||
|
<RouterLink class="brand-lockup" to="/" aria-label="Pokopia Wiki" @click="closeSidebar">
|
||||||
|
<PokeBallMark size="42px" />
|
||||||
|
<span>
|
||||||
|
<span class="pokemon-word">Pokopia</span>
|
||||||
|
<span class="brand-subtitle">Community Wiki</span>
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sidebar-collapse-toggle"
|
||||||
|
type="button"
|
||||||
|
:aria-label="sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')"
|
||||||
|
:aria-expanded="!sidebarCollapsed"
|
||||||
|
aria-controls="app-sidebar"
|
||||||
|
@focus="showSidebarTooltip(sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar'), $event, false)"
|
||||||
|
@blur="hideSidebarTooltip"
|
||||||
|
@pointerenter="showSidebarTooltip(sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar'), $event, false)"
|
||||||
|
@pointerleave="hideSidebarTooltip"
|
||||||
|
@click="toggleSidebarCollapsed"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="iconChevronRight"
|
||||||
|
class="ui-icon sidebar-collapse-toggle__icon"
|
||||||
|
:class="{ 'sidebar-collapse-toggle__icon--expanded': !sidebarCollapsed }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav ref="sideNav" class="side-nav" :aria-label="t('nav.main')" @scroll="updateSidebarTooltipPosition">
|
||||||
|
<template v-for="item in navItems" :key="navItemKey(item)">
|
||||||
|
<div v-if="isNavGroup(item)" class="side-nav__group" :class="{ 'side-nav__group--active': isNavGroupActive(item) }">
|
||||||
|
<button
|
||||||
|
class="side-nav__link side-nav__group-trigger"
|
||||||
|
:class="{ 'router-link-active': isNavGroupActive(item) }"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="isNavGroupExpanded(item)"
|
||||||
|
:aria-controls="`side-nav-group-${item.key}`"
|
||||||
|
:aria-label="item.label"
|
||||||
|
@focus="showSidebarTooltip(item.label, $event)"
|
||||||
|
@blur="hideSidebarTooltip"
|
||||||
|
@pointerenter="showSidebarTooltip(item.label, $event)"
|
||||||
|
@pointerleave="hideSidebarTooltip"
|
||||||
|
@click="toggleNavGroup(item.key)"
|
||||||
|
>
|
||||||
|
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
|
||||||
|
<span class="side-nav__label">{{ item.label }}</span>
|
||||||
|
<Icon
|
||||||
|
:icon="isNavGroupExpanded(item) ? iconChevronDown : iconChevronRight"
|
||||||
|
class="ui-icon side-nav__chevron"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div v-if="isNavGroupExpanded(item)" :id="`side-nav-group-${item.key}`" class="side-nav__children">
|
||||||
|
<RouterLink
|
||||||
|
v-for="child in item.children"
|
||||||
|
:key="child.to"
|
||||||
|
class="side-nav__link side-nav__link--child"
|
||||||
|
:class="{ 'router-link-active': isNavActive(child.to) }"
|
||||||
|
:to="child.to"
|
||||||
|
:aria-label="child.label"
|
||||||
|
@focus="showSidebarTooltip(child.label, $event)"
|
||||||
|
@blur="hideSidebarTooltip"
|
||||||
|
@pointerenter="showSidebarTooltip(child.label, $event)"
|
||||||
|
@pointerleave="hideSidebarTooltip"
|
||||||
|
@click="closeSidebar"
|
||||||
|
>
|
||||||
|
<Icon v-if="child.icon" :icon="child.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
|
||||||
|
<span class="side-nav__label">{{ child.label }}</span>
|
||||||
|
<StatusBadge
|
||||||
|
v-if="child.badge"
|
||||||
|
class="side-nav__badge"
|
||||||
|
:label="child.badge.label"
|
||||||
|
:tone="child.badge.tone"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-else
|
||||||
|
class="side-nav__link"
|
||||||
|
:class="{ 'router-link-active': isNavActive(item.to) }"
|
||||||
|
:to="item.to"
|
||||||
|
:aria-label="item.label"
|
||||||
|
@focus="showSidebarTooltip(item.label, $event)"
|
||||||
|
@blur="hideSidebarTooltip"
|
||||||
|
@pointerenter="showSidebarTooltip(item.label, $event)"
|
||||||
|
@pointerleave="hideSidebarTooltip"
|
||||||
|
@click="closeSidebar"
|
||||||
|
>
|
||||||
|
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
|
||||||
|
<span class="side-nav__label">{{ item.label }}</span>
|
||||||
|
<StatusBadge
|
||||||
|
v-if="item.badge"
|
||||||
|
class="side-nav__badge"
|
||||||
|
:label="item.badge.label"
|
||||||
|
:tone="item.badge.tone"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="sidebarTooltip"
|
||||||
|
id="sidebar-tooltip"
|
||||||
|
class="sidebar-tooltip"
|
||||||
|
role="tooltip"
|
||||||
|
:style="{ top: `${sidebarTooltip.top}px`, left: `${sidebarTooltip.left}px` }"
|
||||||
|
>
|
||||||
|
{{ sidebarTooltip.label }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="site-footer__inner">
|
||||||
|
<p class="site-footer__copyright">
|
||||||
|
{{ t('legal.footer.copyright', { year: copyrightYear }) }}
|
||||||
|
</p>
|
||||||
|
<nav class="site-footer__links" :aria-label="t('legal.footer.linksLabel')">
|
||||||
|
<RouterLink to="/privacy-policy" @click="closeSidebar">{{ t('legal.footer.privacy') }}</RouterLink>
|
||||||
|
<RouterLink to="/terms-of-service" @click="closeSidebar">{{ t('legal.footer.terms') }}</RouterLink>
|
||||||
|
<RouterLink to="/disclaimers" @click="closeSidebar">{{ t('legal.footer.disclaimers') }}</RouterLink>
|
||||||
|
</nav>
|
||||||
|
<p class="site-footer__notice">{{ t('legal.footer.notice') }}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
47
frontend/src/components/ConfirmDialog.vue
Normal file
47
frontend/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import Modal from './Modal.vue';
|
||||||
|
import { iconCancel, iconDelete } from '../icons';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
closeLabel: string;
|
||||||
|
busy?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
busy: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
cancel: [];
|
||||||
|
confirm: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:title="title"
|
||||||
|
:close-label="closeLabel"
|
||||||
|
:close-on-backdrop="!busy"
|
||||||
|
:close-on-escape="!busy"
|
||||||
|
@close="emit('cancel')"
|
||||||
|
>
|
||||||
|
<p class="confirm-dialog__message">{{ message }}</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button type="button" class="link-button link-button--danger" :disabled="busy" @click="emit('confirm')">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ confirmLabel }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="emit('cancel')">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ cancelLabel }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import EditMeta from './EditMeta.vue';
|
||||||
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
entity: EditInfo;
|
entity: EditInfo;
|
||||||
history: EditHistoryEntry[];
|
history: EditHistoryEntry[];
|
||||||
}>();
|
}>();
|
||||||
@@ -12,8 +13,16 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
Name: 'common.name',
|
Name: 'common.name',
|
||||||
名字: 'common.name',
|
名字: 'common.name',
|
||||||
名称: 'common.name',
|
名称: 'common.name',
|
||||||
|
Title: 'pages.checklist.task',
|
||||||
|
标题: 'pages.checklist.task',
|
||||||
|
'Pokemon ID': 'pages.pokemon.id',
|
||||||
|
'Pokopia ID': 'pages.pokemon.id',
|
||||||
|
'Event item': 'common.eventItem',
|
||||||
|
'Event Pokemon': 'pages.pokemon.eventItem',
|
||||||
|
'Event Habitat': 'pages.habitats.eventItem',
|
||||||
Genus: 'pages.pokemon.genus',
|
Genus: 'pages.pokemon.genus',
|
||||||
Details: 'pages.pokemon.details',
|
Details: 'pages.pokemon.details',
|
||||||
|
Description: 'pages.items.description',
|
||||||
介绍: 'pages.pokemon.details',
|
介绍: 'pages.pokemon.details',
|
||||||
Image: 'pages.pokemon.image',
|
Image: 'pages.pokemon.image',
|
||||||
图片: 'pages.pokemon.image',
|
图片: 'pages.pokemon.image',
|
||||||
@@ -37,14 +46,23 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
'Speciality drops': 'pages.pokemon.skillDrops',
|
'Speciality drops': 'pages.pokemon.skillDrops',
|
||||||
'Skill drops': 'pages.pokemon.skillDrops',
|
'Skill drops': 'pages.pokemon.skillDrops',
|
||||||
特长掉落物: 'pages.pokemon.skillDrops',
|
特长掉落物: 'pages.pokemon.skillDrops',
|
||||||
|
Trading: 'pages.pokemon.trading',
|
||||||
|
'Trading items': 'pages.pokemon.tradingItems',
|
||||||
Category: 'pages.items.category',
|
Category: 'pages.items.category',
|
||||||
分类: 'pages.items.category',
|
分类: 'pages.items.category',
|
||||||
Usage: 'pages.items.usage',
|
Usage: 'pages.items.usage',
|
||||||
用途: 'pages.items.usage',
|
用途: 'pages.items.usage',
|
||||||
|
'Base Price': 'pages.items.basePrice',
|
||||||
|
'Base price': 'pages.items.basePrice',
|
||||||
|
基础价格: 'pages.items.basePrice',
|
||||||
|
Dyeability: 'pages.items.dyeability',
|
||||||
|
染色能力: 'pages.items.dyeability',
|
||||||
Dyeable: 'pages.items.dyeable',
|
Dyeable: 'pages.items.dyeable',
|
||||||
可染色: 'pages.items.dyeable',
|
可染色: 'pages.items.dyeable',
|
||||||
'Dual dyeable': 'pages.items.dualDyeable',
|
'Dual dyeable': 'pages.items.dualDyeable',
|
||||||
可双区染色: 'pages.items.dualDyeable',
|
可双区染色: 'pages.items.dualDyeable',
|
||||||
|
'Triple dyeable': 'pages.items.tripleDyeable',
|
||||||
|
可三区染色: 'pages.items.tripleDyeable',
|
||||||
'Pattern editable': 'pages.items.patternEditable',
|
'Pattern editable': 'pages.items.patternEditable',
|
||||||
可改花纹: 'pages.items.patternEditable',
|
可改花纹: 'pages.items.patternEditable',
|
||||||
'No recipe': 'pages.items.noRecipe',
|
'No recipe': 'pages.items.noRecipe',
|
||||||
@@ -60,7 +78,14 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
Item: 'pages.recipes.item',
|
Item: 'pages.recipes.item',
|
||||||
物品: 'pages.recipes.item',
|
物品: 'pages.recipes.item',
|
||||||
Materials: 'pages.recipes.materials',
|
Materials: 'pages.recipes.materials',
|
||||||
需要材料: 'pages.recipes.materials'
|
需要材料: 'pages.recipes.materials',
|
||||||
|
'Sort order': 'pages.admin.sortOrder',
|
||||||
|
排序: 'pages.admin.sortOrder',
|
||||||
|
'Has item drop': 'pages.admin.hasItemDrop',
|
||||||
|
有掉落物: 'pages.admin.hasItemDrop',
|
||||||
|
'Has trading': 'pages.admin.hasTrading',
|
||||||
|
'有 Trading': 'pages.admin.hasTrading',
|
||||||
|
ChangeLog: 'pages.admin.changeLog'
|
||||||
};
|
};
|
||||||
|
|
||||||
function displayName(user: UserSummary | null): string {
|
function displayName(user: UserSummary | null): string {
|
||||||
@@ -76,6 +101,14 @@ function actionMark(action: EditHistoryAction): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function changeLabel(label: string): string {
|
function changeLabel(label: string): string {
|
||||||
|
const localizedFieldMatch = label.match(/^(.+) \(([^()]+)\)$/);
|
||||||
|
if (localizedFieldMatch) {
|
||||||
|
const [, fieldLabel, languageCode] = localizedFieldMatch;
|
||||||
|
if (fieldLabel && languageCode) {
|
||||||
|
return `${changeLabel(fieldLabel)} (${languageCode})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const key = changeLabelKeys[label];
|
const key = changeLabelKeys[label];
|
||||||
return key ? t(key) : label;
|
return key ? t(key) : label;
|
||||||
}
|
}
|
||||||
@@ -84,6 +117,14 @@ function changeValue(value: string): string {
|
|||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
None: t('common.none'),
|
None: t('common.none'),
|
||||||
无: t('common.none'),
|
无: t('common.none'),
|
||||||
|
'Not dyeable': t('pages.items.notDyeable'),
|
||||||
|
不可染色: t('pages.items.notDyeable'),
|
||||||
|
Dyeable: t('pages.items.dyeable'),
|
||||||
|
可染色: t('pages.items.dyeable'),
|
||||||
|
'Dual dyeable': t('pages.items.dualDyeable'),
|
||||||
|
可双区染色: t('pages.items.dualDyeable'),
|
||||||
|
'Triple dyeable': t('pages.items.tripleDyeable'),
|
||||||
|
可三区染色: t('pages.items.tripleDyeable'),
|
||||||
Yes: locale.value === 'zh-CN' ? '是' : 'Yes',
|
Yes: locale.value === 'zh-CN' ? '是' : 'Yes',
|
||||||
是: locale.value === 'zh-CN' ? '是' : 'Yes',
|
是: locale.value === 'zh-CN' ? '是' : 'Yes',
|
||||||
No: locale.value === 'zh-CN' ? '否' : 'No',
|
No: locale.value === 'zh-CN' ? '否' : 'No',
|
||||||
@@ -92,12 +133,21 @@ function changeValue(value: string): string {
|
|||||||
return values[value] ?? value;
|
return values[value] ?? value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visibleChanges(entry: EditHistoryEntry) {
|
||||||
|
return entry.changes.filter((change) => change.label !== 'Display ID' && change.label !== 'Sort order' && change.label !== '排序');
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleHistoryEntries() {
|
||||||
|
return props.history.filter((entry) => entry.action !== 'update' || visibleChanges(entry).length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function historySummary(entry: EditHistoryEntry): string {
|
function historySummary(entry: EditHistoryEntry): string {
|
||||||
if (!entry.changes.length) {
|
const changes = visibleChanges(entry);
|
||||||
|
if (!changes.length) {
|
||||||
return actionLabel(entry.action);
|
return actionLabel(entry.action);
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry.changes.map((change) => changeLabel(change.label)).join(locale.value === 'zh-CN' ? '、' : ', ');
|
return changes.map((change) => changeLabel(change.label)).join(locale.value === 'zh-CN' ? '、' : ', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string): string {
|
function formatDateTime(value: string): string {
|
||||||
@@ -118,23 +168,25 @@ function formatDateTime(value: string): string {
|
|||||||
<div>
|
<div>
|
||||||
<dt>{{ t('history.createdBy') }}</dt>
|
<dt>{{ t('history.createdBy') }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<strong>{{ displayName(entity.createdBy) }}</strong>
|
<RouterLink v-if="props.entity.createdBy" class="user-profile-link" :to="`/profile/${props.entity.createdBy.id}`">
|
||||||
<time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time>
|
{{ props.entity.createdBy.displayName }}
|
||||||
|
</RouterLink>
|
||||||
|
<strong v-else>{{ displayName(props.entity.createdBy) }}</strong>
|
||||||
|
<time :datetime="props.entity.createdAt">{{ formatDateTime(props.entity.createdAt) }}</time>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ t('history.lastEdited') }}</dt>
|
<dt>{{ t('history.lastEdited') }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<strong>{{ displayName(entity.updatedBy) }}</strong>
|
<EditMeta :entity="props.entity" :show-label="false" />
|
||||||
<time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<section class="edit-history-list" aria-labelledby="edit-history-list-title">
|
<section class="edit-history-list" aria-labelledby="edit-history-list-title">
|
||||||
<h3 id="edit-history-list-title">{{ t('history.editHistory') }}</h3>
|
<h3 id="edit-history-list-title">{{ t('history.editHistory') }}</h3>
|
||||||
<ol v-if="history.length" class="edit-timeline">
|
<ol v-if="visibleHistoryEntries().length" class="edit-timeline">
|
||||||
<li v-for="entry in history" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
|
<li v-for="entry in visibleHistoryEntries()" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
|
||||||
<span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span>
|
<span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span>
|
||||||
<div class="edit-timeline__body">
|
<div class="edit-timeline__body">
|
||||||
<details class="edit-history-entry">
|
<details class="edit-history-entry">
|
||||||
@@ -143,8 +195,8 @@ function formatDateTime(value: string): string {
|
|||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="edit-history-entry__content">
|
<div class="edit-history-entry__content">
|
||||||
<dl v-if="entry.changes.length" class="edit-change-list">
|
<dl v-if="visibleChanges(entry).length" class="edit-change-list">
|
||||||
<div v-for="change in entry.changes" :key="`${change.label}-${change.before}-${change.after}`">
|
<div v-for="change in visibleChanges(entry)" :key="`${change.label}-${change.before}-${change.after}`">
|
||||||
<dt>{{ changeLabel(change.label) }}</dt>
|
<dt>{{ changeLabel(change.label) }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<span class="edit-change-list__label">{{ t('history.before') }}</span>
|
<span class="edit-change-list__label">{{ t('history.before') }}</span>
|
||||||
@@ -158,7 +210,12 @@ function formatDateTime(value: string): string {
|
|||||||
<dl class="edit-history-detail-meta">
|
<dl class="edit-history-detail-meta">
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ t('history.author') }}</dt>
|
<dt>{{ t('history.author') }}</dt>
|
||||||
<dd>{{ displayName(entry.user) }}</dd>
|
<dd>
|
||||||
|
<RouterLink v-if="entry.user" class="user-profile-link" :to="`/profile/${entry.user.id}`">
|
||||||
|
{{ entry.user.displayName }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else>{{ displayName(entry.user) }}</span>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ t('history.time') }}</dt>
|
<dt>{{ t('history.time') }}</dt>
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { EditInfo } from '../services/api';
|
import type { EditInfo } from '../services/api';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
entity: EditInfo;
|
entity: EditInfo;
|
||||||
}>();
|
showLabel?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
showLabel: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
@@ -18,6 +24,11 @@ function formatDateTime(value: string): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p class="edit-meta">
|
<p class="edit-meta">
|
||||||
{{ t('history.lastEdited') }}: {{ entity.updatedBy?.displayName ?? t('common.system') }} / {{ formatDateTime(entity.updatedAt) }}
|
<template v-if="showLabel">{{ t('history.lastEdited') }}: </template>
|
||||||
|
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
|
||||||
|
{{ entity.updatedBy.displayName }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else>{{ t('common.system') }}</span>
|
||||||
|
/ <time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,33 +10,51 @@ defineProps<{
|
|||||||
icon?: AppIcon;
|
icon?: AppIcon;
|
||||||
marker?: string;
|
marker?: string;
|
||||||
image?: { src: string; alt: string };
|
image?: { src: string; alt: string };
|
||||||
|
ribbon?: string;
|
||||||
|
compactTooltip?: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
|
<RouterLink
|
||||||
|
v-if="to"
|
||||||
|
class="entity-card entity-card--link"
|
||||||
|
:class="{ 'entity-card--collection-compact': compactTooltip }"
|
||||||
|
:to="to"
|
||||||
|
:aria-label="compactTooltip ? title : undefined"
|
||||||
|
>
|
||||||
|
<span v-if="ribbon" class="entity-card__ribbon-clip" aria-hidden="true">
|
||||||
|
<span class="entity-card__ribbon">{{ ribbon }}</span>
|
||||||
|
</span>
|
||||||
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||||
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
||||||
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||||
<PokeBallMark v-else-if="!marker" size="30px" />
|
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||||
<span v-else>{{ marker }}</span>
|
<span v-else>{{ marker }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="compactTooltip" class="entity-card__tooltip" role="tooltip">{{ title }}</span>
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
<span class="entity-card__title">{{ title }}</span>
|
<span class="entity-card__title">{{ title }}</span>
|
||||||
|
<slot name="after-title"></slot>
|
||||||
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<article v-else class="entity-card">
|
<article v-else class="entity-card" :class="{ 'entity-card--collection-compact': compactTooltip }">
|
||||||
|
<span v-if="ribbon" class="entity-card__ribbon-clip" aria-hidden="true">
|
||||||
|
<span class="entity-card__ribbon">{{ ribbon }}</span>
|
||||||
|
</span>
|
||||||
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||||
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
||||||
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||||
<PokeBallMark v-else-if="!marker" size="30px" />
|
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||||
<span v-else>{{ marker }}</span>
|
<span v-else>{{ marker }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="compactTooltip" class="entity-card__tooltip" role="tooltip">{{ title }}</span>
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
<span class="entity-card__title">{{ title }}</span>
|
<span class="entity-card__title">{{ title }}</span>
|
||||||
|
<slot name="after-title"></slot>
|
||||||
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { NamedEntity } from '../services/api';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { iconItem } from '../icons';
|
||||||
|
import type { EntityImage, NamedEntity, PokemonImage } from '../services/api';
|
||||||
|
|
||||||
|
type ChipItem = NamedEntity & {
|
||||||
|
image?: EntityImage | PokemonImage | null;
|
||||||
|
quantity?: number;
|
||||||
|
};
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
items: Array<NamedEntity & { quantity?: number }>;
|
items: ChipItem[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function hasImageSlot(item: ChipItem) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(item, 'image');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
<span v-for="item in items" :key="`${item.id}-${item.name}`" class="chip">
|
<span v-for="item in items" :key="`${item.id}-${item.name}`" class="chip" :class="{ 'chip--with-media': hasImageSlot(item) }">
|
||||||
|
<span v-if="hasImageSlot(item)" class="chip__media" aria-hidden="true">
|
||||||
|
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
|
||||||
|
<Icon v-else :icon="iconItem" class="chip__icon" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
{{ item.name }}<span v-if="item.quantity"> × {{ item.quantity }}</span>
|
{{ item.name }}<span v-if="item.quantity"> × {{ item.quantity }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user