From b428595769cc70a70636093e91dc5c6893ae540e Mon Sep 17 00:00:00 2001 From: xiaomai Date: Wed, 29 Apr 2026 17:46:58 +0800 Subject: [PATCH] initial commit --- .codex | 0 .env.example | 6 + .gitignore | 9 + .npmrc | 2 + AGENTS.md | 155 +++++++++++ DESIGN.md | 116 ++++++++ backend/Dockerfile | 8 + backend/db/schema.sql | 122 ++++++++ backend/db/seed.sql | 147 ++++++++++ backend/package.json | 25 ++ backend/src/db.ts | 31 +++ backend/src/filter.ts | 49 ++++ backend/src/queries.ts | 355 ++++++++++++++++++++++++ backend/src/server.ts | 89 ++++++ backend/tests/filter.test.ts | 33 +++ backend/tsconfig.json | 14 + docker-compose.yml | 42 +++ frontend/Dockerfile | 8 + frontend/index.html | 12 + frontend/package.json | 26 ++ frontend/src/App.vue | 24 ++ frontend/src/components/EntityChips.vue | 16 ++ frontend/src/main.ts | 6 + frontend/src/router/index.ts | 23 ++ frontend/src/services/api.test.ts | 10 + frontend/src/services/api.ts | 120 ++++++++ frontend/src/styles/main.css | 301 ++++++++++++++++++++ frontend/src/views/HabitatDetail.vue | 43 +++ frontend/src/views/HabitatList.vue | 33 +++ frontend/src/views/ItemDetail.vue | 77 +++++ frontend/src/views/ItemsList.vue | 101 +++++++ frontend/src/views/PokemonDetail.vue | 48 ++++ frontend/src/views/PokemonList.vue | 105 +++++++ frontend/src/views/RecipeDetail.vue | 38 +++ frontend/tsconfig.json | 8 + frontend/vite.config.ts | 9 + package.json | 15 + pnpm-workspace.yaml | 3 + 38 files changed, 2229 insertions(+) create mode 100644 .codex create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 AGENTS.md create mode 100644 DESIGN.md create mode 100644 backend/Dockerfile create mode 100644 backend/db/schema.sql create mode 100644 backend/db/seed.sql create mode 100644 backend/package.json create mode 100644 backend/src/db.ts create mode 100644 backend/src/filter.ts create mode 100644 backend/src/queries.ts create mode 100644 backend/src/server.ts create mode 100644 backend/tests/filter.test.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/components/EntityChips.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/services/api.test.ts create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/styles/main.css create mode 100644 frontend/src/views/HabitatDetail.vue create mode 100644 frontend/src/views/HabitatList.vue create mode 100644 frontend/src/views/ItemDetail.vue create mode 100644 frontend/src/views/ItemsList.vue create mode 100644 frontend/src/views/PokemonDetail.vue create mode 100644 frontend/src/views/PokemonList.vue create mode 100644 frontend/src/views/RecipeDetail.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 package.json create mode 100644 pnpm-workspace.yaml diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..df82855 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +POSTGRES_DB=pokopia +POSTGRES_USER=pokopia +POSTGRES_PASSWORD=pokopia +DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia +BACKEND_PORT=3001 +VITE_API_BASE_URL=http://localhost:3001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82b3ec2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.pnpm-store/ +dist/ +.env +.env.* +!.env.example +coverage/ +*.log +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..11a3bdd --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +store-dir=.pnpm-store +package-import-method=copy diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4493099 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,155 @@ +# AGENTS.md + +## Core Principles + +* Always read `DESIGN.md` before making any change. +* Follow the existing structure and conventions strictly. +* Make **minimal, targeted changes only**. Do not refactor unrelated code. +* Prefer clarity over cleverness. Avoid unnecessary abstraction. + +--- + +## Mandatory Workflow (MUST FOLLOW) + +For any non-trivial task: + +1. **Read `DESIGN.md`** +2. **Produce a short plan (no code)** +3. Wait for approval +4. Implement in small steps +5. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL. + +Do NOT skip planning. + +--- + +## Scope Control (Prevent Code Bloat) + +* Only modify files directly related to the task. +* Do NOT: + + * Introduce new layers (services, utils, hooks, etc.) unless clearly required + * Split files unnecessarily + * Rewrite existing modules without explicit instruction +* Prefer editing existing files over creating new ones. +* Keep functions and components small and readable. + +If a task grows beyond scope, STOP and ask. + +--- + +## UI Safety Rules (CRITICAL) + +User-facing UI must NEVER contain: + +* prompts +* remarks +* planning notes +* debug messages +* explanations of what was changed +* internal field names like `debug`, `meta`, `internal` + +### Strict Rules + +* Only render **business data** and intended UI text +* Never display: + + * "Updated successfully because..." + * "Changed X to Y" + * "TODO", "NOTE", "DEBUG" +* Debug information must go to logs, not UI +* Separate internal data from API responses + +Violations are considered critical errors. + +--- + +## Data & API Design Rules + +* Follow `DESIGN.md` as the **single source of truth** +* PostgreSQL: + + * use `snake_case` + * define proper primary/foreign keys + * avoid premature optimization +* APIs: + + * return only necessary fields + * do not expose internal metadata + +--- + +## Code Style & Structure + +* Vue: + + * Components: `PascalCase` + * Composables: `useXxx` +* General: + + * variables/functions: `camelCase` +* Keep files focused and under reasonable length +* Avoid duplication + +--- + +## Testing & Validation + +This project is developed from WSL, but runtime validation is done through Docker. + +Agent workflow: + +* Run: + + * lint + * typecheck + +* Do NOT run tests in WSL. +* Do NOT require local test execution before finishing a task. +* The user will run `docker compose up --build`. +* If Docker reports errors, the user will paste the error output and the agent will fix those errors in a follow-up pass. + +When adding tests is clearly useful, keep them focused and minimal, but do not execute them locally unless explicitly requested. + +--- + +## Definition of Done + +A task is complete ONLY IF: + +* Matches `DESIGN.md` +* Minimal diff (no unrelated changes) +* No UI leaks of internal info +* Code is readable and concise +* Passes lint/typecheck when practical +* Docker runtime issues are handled from user-provided `docker compose up --build` output + +--- + +## Anti-Patterns (STRICTLY FORBIDDEN) + +* Adding UI remarks like "I updated this" +* Over-engineering simple features +* Creating unused files or abstractions +* Mixing internal/debug data into UI +* Large, unfocused commits +* Silent behavior changes outside scope + +--- + +## When Unsure + +* Ask for clarification +* Do not guess requirements +* Do not invent features not in `DESIGN.md` + +--- + +## Project Context + +* Goal: Pokopia Wiki +* Stack: + + * Frontend: Vue + * Backend: Node + PostgreSQL + * Infra: Docker diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..57fba2f --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,116 @@ +# Pokopia Wiki + +## 技术栈 + +- 后端:Postgresql +- 前端:Vue +- 运维:Docker +都要用最新的框架 + +# 功能描述 + +- 一个具有社区功能的 Pokopia 游戏 Wiki + +## 数据 + +Pokemon 可配置: +- ID +- 名字 +- 特长(可多选,最多 2 个) +- 喜欢的环境(单选) +- 喜欢的东西(可多选,最多 6 个) +- 出现的栖息地(可多选) + +特长 可配置: +- 名称 +- 二级分类(可空,用于给乱撒这类特长做二级分类) +Eg: 名称:乱撒,二级分类:棉花 + +喜欢的环境 可配置: +- 名称 + +喜欢的东西(标签) 可配置: +- 名称 + +物品 可配置: +- 名称 +- 分类 +- 用途 +- 入手方式(可多选) +- 客制化: + - 可染色 + - 可双区染色 + - 可改花纹 +- 标签(多选) + +材料单 可配置: +- 名称 +- 入手方式(可多选) +- 需要材料(可多样,多数量) + +物品 / 材料单分类: +- 名称 + +物品 / 材料单用途: +- 名称 + +入手方式 可配置: +- 名称 + +地图: +- 名称 + +栖息地: +- 名称 +- 配方(物品,数量) +- 可出现的宝可梦(可多选) + +出现契机 +- 时间:早晨 / 中午 / 傍晚 / 晚上 +- 天气:晴天 / 阴天 / 雨天 +- 稀有度:1 ~ 3 星 +- 地图关联 + +## 功能 + +- Pokemon 列表 + - 搜索 + - 筛选 + - 特长(可多选,满足任意条件 / 满足全部条件) + - 喜欢的环境 + - 喜欢的东西(可多选,满足任意条件 / 满足全部条件) +- Pokemon 详情页 + - 特长 + - 喜欢的环境 + - 喜欢的东西 + - 栖息地 +- 栖息地列表 +- 栖息地详情页 + - 配方列表 + - 可能出现的宝可梦列表 + - 出现时间 + - 出现天气 + - 稀有度 + - 出现的地图列表 +- 物品 / 材料单列表 + - 根据分类显示(标签页) + - 筛选 + - 用途 + - 标签 +- 物品详情页 + - 基本信息 + - 用途 + - 入手方式 + - 自定义 + - 可染色 + - 可双区染色 + - 可改花纹 + - 材料单信息 + - 入手方式 + - 需要材料列表 + - 标签 + - 相关栖息地 +- 材料单详情页 + - 基本信息 + - 入手方式 + - 需要材料列表 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..aff12c2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine + +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +EXPOSE 3001 +CMD ["npm", "run", "start"] diff --git a/backend/db/schema.sql b/backend/db/schema.sql new file mode 100644 index 0000000..860cd82 --- /dev/null +++ b/backend/db/schema.sql @@ -0,0 +1,122 @@ +CREATE TABLE IF NOT EXISTS environments ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS skills ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL, + subcategory text, + UNIQUE (name, subcategory) +); + +CREATE TABLE IF NOT EXISTS favorite_things ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS pokemon ( + id integer PRIMARY KEY, + name text NOT NULL UNIQUE, + environment_id integer NOT NULL REFERENCES environments(id) +); + +CREATE TABLE IF NOT EXISTS pokemon_skills ( + pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, + skill_id integer NOT NULL REFERENCES skills(id), + PRIMARY KEY (pokemon_id, skill_id) +); + +CREATE TABLE IF NOT EXISTS pokemon_favorite_things ( + pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, + favorite_thing_id integer NOT NULL REFERENCES favorite_things(id), + PRIMARY KEY (pokemon_id, favorite_thing_id) +); + +CREATE TABLE IF NOT EXISTS item_categories ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS item_usages ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS acquisition_methods ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS item_tags ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS recipes ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS recipe_acquisition_methods ( + recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id), + PRIMARY KEY (recipe_id, acquisition_method_id) +); + +CREATE TABLE IF NOT EXISTS items ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE, + category_id integer NOT NULL REFERENCES item_categories(id), + usage_id integer NOT NULL REFERENCES item_usages(id), + recipe_id integer REFERENCES recipes(id), + dyeable boolean NOT NULL DEFAULT false, + dual_dyeable boolean NOT NULL DEFAULT false, + pattern_editable boolean NOT NULL DEFAULT false +); + +CREATE TABLE IF NOT EXISTS item_acquisition_methods ( + item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE, + acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id), + PRIMARY KEY (item_id, acquisition_method_id) +); + +CREATE TABLE IF NOT EXISTS item_item_tags ( + item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE, + item_tag_id integer NOT NULL REFERENCES item_tags(id), + PRIMARY KEY (item_id, item_tag_id) +); + +CREATE TABLE IF NOT EXISTS recipe_materials ( + recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + item_id integer NOT NULL REFERENCES items(id), + quantity integer NOT NULL CHECK (quantity > 0), + PRIMARY KEY (recipe_id, item_id) +); + +CREATE TABLE IF NOT EXISTS maps ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS habitats ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS habitat_recipe_items ( + habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE, + item_id integer NOT NULL REFERENCES items(id), + quantity integer NOT NULL CHECK (quantity > 0), + PRIMARY KEY (habitat_id, item_id) +); + +CREATE TABLE IF NOT EXISTS habitat_pokemon ( + habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE, + pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, + map_id integer NOT NULL REFERENCES maps(id), + time_of_day text NOT NULL CHECK (time_of_day IN ('早晨', '中午', '傍晚', '晚上')), + weather text NOT NULL CHECK (weather IN ('晴天', '阴天', '雨天')), + rarity integer NOT NULL CHECK (rarity BETWEEN 1 AND 3), + PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather) +); diff --git a/backend/db/seed.sql b/backend/db/seed.sql new file mode 100644 index 0000000..1282dfd --- /dev/null +++ b/backend/db/seed.sql @@ -0,0 +1,147 @@ +INSERT INTO environments (id, name) VALUES + (1, '森林'), + (2, '水边'), + (3, '草原') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO skills (id, name, subcategory) VALUES + (1, '采集', NULL), + (2, '乱撒', '棉花'), + (3, '浇水', NULL), + (4, '搬运', NULL) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO favorite_things (id, name) VALUES + (1, '莓果'), + (2, '花朵'), + (3, '木材'), + (4, '清水'), + (5, '棉花') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO pokemon (id, name, environment_id) VALUES + (1, '妙蛙种子', 1), + (4, '小火龙', 3), + (7, '杰尼龟', 2), + (25, '皮卡丘', 3) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO pokemon_skills (pokemon_id, skill_id) VALUES + (1, 1), + (1, 3), + (4, 4), + (7, 3), + (25, 1), + (25, 2) +ON CONFLICT DO NOTHING; + +INSERT INTO pokemon_favorite_things (pokemon_id, favorite_thing_id) VALUES + (1, 1), + (1, 2), + (4, 3), + (7, 4), + (25, 1), + (25, 5) +ON CONFLICT DO NOTHING; + +INSERT INTO item_categories (id, name) VALUES + (1, '家具'), + (2, '材料'), + (3, '装饰') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO item_usages (id, name) VALUES + (1, '栖息地配方'), + (2, '建造'), + (3, '装饰') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO acquisition_methods (id, name) VALUES + (1, '采集'), + (2, '制作'), + (3, '探索') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO item_tags (id, name) VALUES + (1, '自然'), + (2, '木质'), + (3, '柔软'), + (4, '水域') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO recipes (id, name) VALUES + (1, '木质长椅材料单'), + (2, '棉花垫材料单') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO items (id, name, category_id, usage_id, recipe_id, dyeable, dual_dyeable, pattern_editable) VALUES + (1, '原木', 2, 2, NULL, false, false, false), + (2, '棉花', 2, 2, NULL, true, false, false), + (3, '木质长椅', 1, 3, 1, true, true, false), + (4, '清水瓶', 3, 1, NULL, false, false, true), + (5, '棉花垫', 1, 3, 2, true, false, true) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES + (1, 1), + (2, 1), + (3, 2), + (4, 3), + (5, 2) +ON CONFLICT DO NOTHING; + +INSERT INTO item_item_tags (item_id, item_tag_id) VALUES + (1, 1), + (1, 2), + (2, 3), + (3, 2), + (4, 4), + (5, 3) +ON CONFLICT DO NOTHING; + +INSERT INTO recipe_acquisition_methods (recipe_id, acquisition_method_id) VALUES + (1, 2), + (2, 2) +ON CONFLICT DO NOTHING; + +INSERT INTO recipe_materials (recipe_id, item_id, quantity) VALUES + (1, 1, 6), + (2, 2, 4) +ON CONFLICT DO NOTHING; + +INSERT INTO maps (id, name) VALUES + (1, '起始平原'), + (2, '微风森林'), + (3, '湖畔小径') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO habitats (id, name) VALUES + (1, '绿荫营地'), + (2, '湖边小窝') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO habitat_recipe_items (habitat_id, item_id, quantity) VALUES + (1, 1, 8), + (1, 3, 1), + (2, 2, 3), + (2, 4, 2) +ON CONFLICT DO NOTHING; + +INSERT INTO habitat_pokemon (habitat_id, pokemon_id, map_id, time_of_day, weather, rarity) VALUES + (1, 1, 2, '早晨', '晴天', 1), + (1, 25, 1, '傍晚', '阴天', 2), + (2, 7, 3, '中午', '雨天', 1), + (2, 1, 3, '早晨', '雨天', 2) +ON CONFLICT DO NOTHING; + +SELECT setval(pg_get_serial_sequence('environments', 'id'), (SELECT max(id) FROM environments)); +SELECT setval(pg_get_serial_sequence('skills', 'id'), (SELECT max(id) FROM skills)); +SELECT setval(pg_get_serial_sequence('favorite_things', 'id'), (SELECT max(id) FROM favorite_things)); +SELECT setval(pg_get_serial_sequence('item_categories', 'id'), (SELECT max(id) FROM item_categories)); +SELECT setval(pg_get_serial_sequence('item_usages', 'id'), (SELECT max(id) FROM item_usages)); +SELECT setval(pg_get_serial_sequence('acquisition_methods', 'id'), (SELECT max(id) FROM acquisition_methods)); +SELECT setval(pg_get_serial_sequence('item_tags', 'id'), (SELECT max(id) FROM item_tags)); +SELECT setval(pg_get_serial_sequence('recipes', 'id'), (SELECT max(id) FROM recipes)); +SELECT setval(pg_get_serial_sequence('items', 'id'), (SELECT max(id) FROM items)); +SELECT setval(pg_get_serial_sequence('maps', 'id'), (SELECT max(id) FROM maps)); +SELECT setval(pg_get_serial_sequence('habitats', 'id'), (SELECT max(id) FROM habitats)); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..0022d21 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,25 @@ +{ + "name": "@pokopia/backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "tsx src/server.ts", + "build": "tsc --noEmit", + "lint": "tsc --noEmit", + "typecheck": "tsc --noEmit", + "test": "node --test --import tsx tests/*.test.ts" + }, + "dependencies": { + "@fastify/cors": "latest", + "fastify": "latest", + "pg": "latest" + }, + "devDependencies": { + "@types/node": "latest", + "@types/pg": "latest", + "tsx": "latest", + "typescript": "latest" + } +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..000c305 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,31 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import pg from 'pg'; + +const { Pool } = pg; +type QueryResultRow = pg.QueryResultRow; + +const databaseUrl = process.env.DATABASE_URL ?? 'postgres://pokopia:pokopia@localhost:5432/pokopia'; + +export const pool = new Pool({ + connectionString: databaseUrl +}); + +export async function query(sql: string, params: unknown[] = []): Promise { + const result = await pool.query(sql, params); + return result.rows; +} + +export async function queryOne(sql: string, params: unknown[] = []): Promise { + const rows = await query(sql, params); + return rows[0] ?? null; +} + +export async function initializeDatabase(): Promise { + const dbDir = path.join(process.cwd(), 'db'); + const schema = await readFile(path.join(dbDir, 'schema.sql'), 'utf8'); + const seed = await readFile(path.join(dbDir, 'seed.sql'), 'utf8'); + + await pool.query(schema); + await pool.query(seed); +} diff --git a/backend/src/filter.ts b/backend/src/filter.ts new file mode 100644 index 0000000..bd17ba7 --- /dev/null +++ b/backend/src/filter.ts @@ -0,0 +1,49 @@ +export type MatchMode = 'any' | 'all'; + +export function parseIdList(value: unknown): number[] { + if (typeof value !== 'string' || value.trim() === '') { + return []; + } + + return value + .split(',') + .map((item) => Number(item.trim())) + .filter((item) => Number.isInteger(item) && item > 0); +} + +export function parseMatchMode(value: unknown): MatchMode { + return value === 'all' ? 'all' : 'any'; +} + +export function sqlForRelationFilter( + ids: number[], + mode: MatchMode, + relationTable: string, + ownerColumn: string, + targetColumn: string, + ownerExpression: string, + params: unknown[] +): string { + if (ids.length === 0) { + return ''; + } + + params.push(ids); + const paramIndex = params.length; + + if (mode === 'all') { + return `( + SELECT count(DISTINCT ${targetColumn}) + FROM ${relationTable} + WHERE ${ownerColumn} = ${ownerExpression} + AND ${targetColumn} = ANY($${paramIndex}::int[]) + ) = ${ids.length}`; + } + + return `EXISTS ( + SELECT 1 + FROM ${relationTable} + WHERE ${ownerColumn} = ${ownerExpression} + AND ${targetColumn} = ANY($${paramIndex}::int[]) + )`; +} diff --git a/backend/src/queries.ts b/backend/src/queries.ts new file mode 100644 index 0000000..5585b3c --- /dev/null +++ b/backend/src/queries.ts @@ -0,0 +1,355 @@ +import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts'; +import { query, queryOne } from './db.ts'; + +type QueryValue = string | string[] | undefined; + +type QueryParams = Record; + +function asString(value: QueryValue): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +function optionSelect(tableName: string): Promise> { + return query(`SELECT id, name FROM ${tableName} ORDER BY name`); +} + +const pokemonProjection = ` + SELECT + p.id, + p.name, + json_build_object('id', e.id, 'name', e.name) AS environment, + COALESCE(( + SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'subcategory', s.subcategory) ORDER BY s.name, s.subcategory) + FROM pokemon_skills ps + JOIN skills s ON s.id = ps.skill_id + WHERE ps.pokemon_id = p.id + ), '[]'::json) AS skills, + COALESCE(( + SELECT json_agg(json_build_object('id', ft.id, 'name', ft.name) ORDER BY ft.name) + FROM pokemon_favorite_things pft + JOIN favorite_things ft ON ft.id = pft.favorite_thing_id + WHERE pft.pokemon_id = p.id + ), '[]'::json) AS favorite_things + FROM pokemon p + JOIN environments e ON e.id = p.environment_id +`; + +export async function getOptions() { + const [skills, environments, favoriteThings, itemCategories, itemUsages, itemTags] = await Promise.all([ + query<{ id: number; name: string; subcategory: string | null }>( + 'SELECT id, name, subcategory FROM skills ORDER BY name, subcategory' + ), + optionSelect('environments'), + optionSelect('favorite_things'), + optionSelect('item_categories'), + optionSelect('item_usages'), + optionSelect('item_tags') + ]); + + return { + skills, + environments, + favoriteThings, + itemCategories, + itemUsages, + itemTags + }; +} + +export async function listPokemon(paramsQuery: QueryParams) { + const params: unknown[] = []; + const conditions: string[] = []; + const search = asString(paramsQuery.search)?.trim(); + const environmentId = Number(asString(paramsQuery.environmentId)); + const skillIds = parseIdList(asString(paramsQuery.skillIds)); + const favoriteThingIds = parseIdList(asString(paramsQuery.favoriteThingIds)); + + if (search) { + params.push(`%${search}%`); + conditions.push(`p.name ILIKE $${params.length}`); + } + + if (Number.isInteger(environmentId) && environmentId > 0) { + params.push(environmentId); + conditions.push(`p.environment_id = $${params.length}`); + } + + const skillFilter = sqlForRelationFilter( + skillIds, + parseMatchMode(asString(paramsQuery.skillMode)), + 'pokemon_skills', + 'pokemon_id', + 'skill_id', + 'p.id', + params + ); + if (skillFilter) { + conditions.push(skillFilter); + } + + const favoriteThingFilter = sqlForRelationFilter( + favoriteThingIds, + parseMatchMode(asString(paramsQuery.favoriteThingMode)), + 'pokemon_favorite_things', + 'pokemon_id', + 'favorite_thing_id', + 'p.id', + params + ); + if (favoriteThingFilter) { + conditions.push(favoriteThingFilter); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + return query(`${pokemonProjection} ${whereClause} ORDER BY p.id`, params); +} + +export async function getPokemon(id: number) { + const pokemon = await queryOne(`${pokemonProjection} WHERE p.id = $1`, [id]); + if (!pokemon) { + return null; + } + + const habitats = await query( + ` + SELECT + h.id, + h.name, + hp.time_of_day, + hp.weather, + hp.rarity, + json_build_object('id', m.id, 'name', m.name) AS map + FROM habitat_pokemon hp + JOIN habitats h ON h.id = hp.habitat_id + JOIN maps m ON m.id = hp.map_id + WHERE hp.pokemon_id = $1 + ORDER BY h.name, hp.rarity, m.name + `, + [id] + ); + + return { ...pokemon, habitats }; +} + +export async function listHabitats() { + return query(` + SELECT + h.id, + h.name, + COALESCE(( + SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name) + FROM habitat_recipe_items hri + JOIN items i ON i.id = hri.item_id + WHERE hri.habitat_id = h.id + ), '[]'::json) AS recipe, + COALESCE(( + SELECT json_agg(DISTINCT jsonb_build_object('id', p.id, 'name', p.name)) + FROM habitat_pokemon hp + JOIN pokemon p ON p.id = hp.pokemon_id + WHERE hp.habitat_id = h.id + ), '[]'::json) AS pokemon + FROM habitats h + ORDER BY h.name + `); +} + +export async function getHabitat(id: number) { + const habitat = await queryOne( + ` + SELECT + h.id, + h.name, + COALESCE(( + SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name) + FROM habitat_recipe_items hri + JOIN items i ON i.id = hri.item_id + WHERE hri.habitat_id = h.id + ), '[]'::json) AS recipe + FROM habitats h + WHERE h.id = $1 + `, + [id] + ); + + if (!habitat) { + return null; + } + + const pokemon = await query( + ` + SELECT + p.id, + p.name, + hp.time_of_day, + hp.weather, + hp.rarity, + json_build_object('id', m.id, 'name', m.name) AS map + FROM habitat_pokemon hp + JOIN pokemon p ON p.id = hp.pokemon_id + JOIN maps m ON m.id = hp.map_id + WHERE hp.habitat_id = $1 + ORDER BY hp.rarity, p.id, m.name + `, + [id] + ); + + return { ...habitat, pokemon }; +} + +const itemProjection = ` + SELECT + i.id, + i.name, + json_build_object('id', c.id, 'name', c.name) AS category, + json_build_object('id', u.id, 'name', u.name) AS usage, + json_build_object( + 'dyeable', i.dyeable, + 'dualDyeable', i.dual_dyeable, + 'patternEditable', i.pattern_editable + ) AS customization, + COALESCE(( + SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY t.name) + FROM item_item_tags iit + JOIN item_tags t ON t.id = iit.item_tag_id + WHERE iit.item_id = i.id + ), '[]'::json) AS tags + FROM items i + JOIN item_categories c ON c.id = i.category_id + JOIN item_usages u ON u.id = i.usage_id +`; + +export async function listItems(paramsQuery: QueryParams) { + const params: unknown[] = []; + const conditions: string[] = []; + const categoryId = Number(asString(paramsQuery.categoryId)); + const usageId = Number(asString(paramsQuery.usageId)); + const tagIds = parseIdList(asString(paramsQuery.tagIds)); + const search = asString(paramsQuery.search)?.trim(); + + if (search) { + params.push(`%${search}%`); + conditions.push(`i.name ILIKE $${params.length}`); + } + + if (Number.isInteger(categoryId) && categoryId > 0) { + params.push(categoryId); + conditions.push(`i.category_id = $${params.length}`); + } + + if (Number.isInteger(usageId) && usageId > 0) { + params.push(usageId); + conditions.push(`i.usage_id = $${params.length}`); + } + + const tagFilter = sqlForRelationFilter( + tagIds, + 'any', + 'item_item_tags', + 'item_id', + 'item_tag_id', + 'i.id', + params + ); + if (tagFilter) { + conditions.push(tagFilter); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + return query(`${itemProjection} ${whereClause} ORDER BY c.name, i.name`, params); +} + +export async function getItem(id: number) { + const item = await queryOne(`${itemProjection} WHERE i.id = $1`, [id]); + if (!item) { + return null; + } + + const [acquisitionMethods, recipe, relatedHabitats] = await Promise.all([ + query( + ` + SELECT am.id, am.name + FROM item_acquisition_methods iam + JOIN acquisition_methods am ON am.id = iam.acquisition_method_id + WHERE iam.item_id = $1 + ORDER BY am.name + `, + [id] + ), + queryOne( + ` + SELECT + r.id, + r.name, + COALESCE(( + SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name) + FROM recipe_acquisition_methods ram + JOIN acquisition_methods am ON am.id = ram.acquisition_method_id + WHERE ram.recipe_id = r.id + ), '[]'::json) AS acquisition_methods, + COALESCE(( + SELECT json_agg(json_build_object('id', mi.id, 'name', mi.name, 'quantity', rm.quantity) ORDER BY mi.name) + FROM recipe_materials rm + JOIN items mi ON mi.id = rm.item_id + WHERE rm.recipe_id = r.id + ), '[]'::json) AS materials + FROM items i + JOIN recipes r ON r.id = i.recipe_id + WHERE i.id = $1 + `, + [id] + ), + query( + ` + SELECT h.id, h.name, hri.quantity + FROM habitat_recipe_items hri + JOIN habitats h ON h.id = hri.habitat_id + WHERE hri.item_id = $1 + ORDER BY h.name + `, + [id] + ) + ]); + + return { ...item, acquisitionMethods, recipe, relatedHabitats }; +} + +export async function listRecipes() { + return query(` + SELECT + r.id, + r.name, + COALESCE(( + SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name) + FROM recipe_materials rm + JOIN items i ON i.id = rm.item_id + WHERE rm.recipe_id = r.id + ), '[]'::json) AS materials + FROM recipes r + ORDER BY r.name + `); +} + +export async function getRecipe(id: number) { + return queryOne( + ` + SELECT + r.id, + r.name, + COALESCE(( + SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name) + FROM recipe_acquisition_methods ram + JOIN acquisition_methods am ON am.id = ram.acquisition_method_id + WHERE ram.recipe_id = r.id + ), '[]'::json) AS acquisition_methods, + COALESCE(( + SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name) + FROM recipe_materials rm + JOIN items i ON i.id = rm.item_id + WHERE rm.recipe_id = r.id + ), '[]'::json) AS materials + FROM recipes r + WHERE r.id = $1 + `, + [id] + ); +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..48daad0 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,89 @@ +import cors from '@fastify/cors'; +import Fastify from 'fastify'; +import { initializeDatabase, pool } from './db.ts'; +import { + getHabitat, + getItem, + getOptions, + getPokemon, + getRecipe, + listHabitats, + listItems, + listPokemon, + listRecipes +} from './queries.ts'; + +const app = Fastify({ + logger: true +}); + +await app.register(cors, { + origin: process.env.FRONTEND_ORIGIN ?? true +}); + +app.get('/health', async () => ({ ok: true })); + +app.get('/api/options', async () => getOptions()); + +app.get('/api/pokemon', async (request) => listPokemon(request.query as Record)); + +app.get('/api/pokemon/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const pokemon = await getPokemon(Number(id)); + + if (!pokemon) { + return reply.code(404).send({ message: 'Not found' }); + } + + return pokemon; +}); + +app.get('/api/habitats', async () => listHabitats()); + +app.get('/api/habitats/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const habitat = await getHabitat(Number(id)); + + if (!habitat) { + return reply.code(404).send({ message: 'Not found' }); + } + + return habitat; +}); + +app.get('/api/items', async (request) => listItems(request.query as Record)); + +app.get('/api/items/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const item = await getItem(Number(id)); + + if (!item) { + return reply.code(404).send({ message: 'Not found' }); + } + + return item; +}); + +app.get('/api/recipes', async () => listRecipes()); + +app.get('/api/recipes/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const recipe = await getRecipe(Number(id)); + + if (!recipe) { + return reply.code(404).send({ message: 'Not found' }); + } + + return recipe; +}); + +const port = Number(process.env.BACKEND_PORT ?? 3001); + +try { + await initializeDatabase(); + await app.listen({ host: '0.0.0.0', port }); +} catch (error) { + app.log.error(error); + await pool.end(); + process.exit(1); +} diff --git a/backend/tests/filter.test.ts b/backend/tests/filter.test.ts new file mode 100644 index 0000000..7997da2 --- /dev/null +++ b/backend/tests/filter.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { parseIdList, parseMatchMode, sqlForRelationFilter } from '../src/filter.ts'; + +describe('filter helpers', () => { + it('parses comma separated ids and drops invalid values', () => { + assert.deepEqual(parseIdList('1, 2, x, -1, 3'), [1, 2, 3]); + }); + + it('defaults match mode to any', () => { + assert.equal(parseMatchMode('all'), 'all'); + assert.equal(parseMatchMode('anything'), 'any'); + assert.equal(parseMatchMode(undefined), 'any'); + }); + + it('builds relation filters with bound array parameters', () => { + const params: unknown[] = []; + const sql = sqlForRelationFilter( + [1, 2], + 'all', + 'pokemon_skills', + 'pokemon_id', + 'skill_id', + 'p.id', + params + ); + + assert.equal(params.length, 1); + assert.deepEqual(params[0], [1, 2]); + assert.match(sql, /count\(DISTINCT skill_id\)/); + assert.match(sql, /= 2/); + }); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..a67af79 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2680ce7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + postgres: + image: postgres:18-alpine + environment: + POSTGRES_DB: pokopia + POSTGRES_USER: pokopia + POSTGRES_PASSWORD: pokopia + ports: + - "5432:5432" + volumes: + - postgres18_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pokopia -d pokopia"] + interval: 5s + timeout: 3s + retries: 10 + + backend: + build: + context: ./backend + environment: + DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia + BACKEND_PORT: 3001 + FRONTEND_ORIGIN: http://localhost:3000 + ports: + - "3001:3001" + depends_on: + postgres: + condition: service_healthy + + frontend: + build: + context: ./frontend + environment: + VITE_API_BASE_URL: http://localhost:3001 + ports: + - "3000:3000" + depends_on: + - backend + +volumes: + postgres18_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..db943be --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-alpine + +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +EXPOSE 3000 +CMD ["npm", "run", "dev"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b79715a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Pokopia Wiki + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..89b4d9e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "@pokopia/frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 3000", + "build": "vue-tsc --noEmit && vite build", + "lint": "vue-tsc --noEmit", + "typecheck": "vue-tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@vitejs/plugin-vue": "latest", + "vite": "latest", + "vue": "latest", + "vue-router": "latest" + }, + "devDependencies": { + "@types/node": "latest", + "@vue/tsconfig": "latest", + "typescript": "latest", + "vitest": "latest", + "vue-tsc": "latest" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..05f40aa --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/EntityChips.vue b/frontend/src/components/EntityChips.vue new file mode 100644 index 0000000..0b53f78 --- /dev/null +++ b/frontend/src/components/EntityChips.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..a2d45ef --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import { router } from './router'; +import './styles/main.css'; + +createApp(App).use(router).mount('#app'); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..999c50b --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,23 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import PokemonList from '../views/PokemonList.vue'; +import PokemonDetail from '../views/PokemonDetail.vue'; +import HabitatList from '../views/HabitatList.vue'; +import HabitatDetail from '../views/HabitatDetail.vue'; +import ItemsList from '../views/ItemsList.vue'; +import ItemDetail from '../views/ItemDetail.vue'; +import RecipeDetail from '../views/RecipeDetail.vue'; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', redirect: '/pokemon' }, + { path: '/pokemon', component: PokemonList }, + { path: '/pokemon/:id', component: PokemonDetail }, + { path: '/habitats', component: HabitatList }, + { path: '/habitats/:id', component: HabitatDetail }, + { path: '/items', component: ItemsList }, + { path: '/items/:id', component: ItemDetail }, + { path: '/recipes/:id', component: RecipeDetail } + ], + scrollBehavior: () => ({ top: 0 }) +}); diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts new file mode 100644 index 0000000..0c59a7a --- /dev/null +++ b/frontend/src/services/api.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { buildQuery } from './api'; + +describe('buildQuery', () => { + it('keeps business filters and drops empty values', () => { + expect(buildQuery({ search: '妙蛙', environmentId: 1, skillIds: '', usageId: undefined })).toBe( + '?search=%E5%A6%99%E8%9B%99&environmentId=1' + ); + }); +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..c5bfaa8 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,120 @@ +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'; + +export interface NamedEntity { + id: number; + name: string; +} + +export interface Skill extends NamedEntity { + subcategory: string | null; +} + +export interface Pokemon { + id: number; + name: string; + environment: NamedEntity; + skills: Skill[]; + favorite_things: NamedEntity[]; +} + +export interface PokemonDetail extends Pokemon { + habitats: Array<{ + id: number; + name: string; + time_of_day: string; + weather: string; + rarity: number; + map: NamedEntity; + }>; +} + +export interface Habitat { + id: number; + name: string; + recipe: Array; + pokemon?: NamedEntity[]; +} + +export interface HabitatDetail extends Habitat { + pokemon: Array; +} + +export interface Item { + id: number; + name: string; + category: NamedEntity; + usage: NamedEntity; + customization: { + dyeable: boolean; + dualDyeable: boolean; + patternEditable: boolean; + }; + tags: NamedEntity[]; +} + +export interface ItemDetail extends Item { + acquisitionMethods: NamedEntity[]; + recipe: RecipeDetail | null; + relatedHabitats: Array; +} + +export interface Recipe { + id: number; + name: string; + materials: Array; +} + +export interface RecipeDetail extends Recipe { + acquisition_methods: NamedEntity[]; +} + +export interface Options { + skills: Skill[]; + environments: NamedEntity[]; + favoriteThings: NamedEntity[]; + itemCategories: NamedEntity[]; + itemUsages: NamedEntity[]; + itemTags: NamedEntity[]; +} + +export function buildQuery(params: Record): string { + const search = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + search.set(key, String(value)); + } + }); + + const query = search.toString(); + return query ? `?${query}` : ''; +} + +async function getJson(path: string): Promise { + const response = await fetch(`${apiBaseUrl}${path}`); + + if (!response.ok) { + throw new Error(`Request failed with ${response.status}`); + } + + return response.json() as Promise; +} + +export const api = { + options: () => getJson('/api/options'), + pokemon: (params: Record) => + getJson(`/api/pokemon${buildQuery(params)}`), + pokemonDetail: (id: string | number) => getJson(`/api/pokemon/${id}`), + habitats: () => getJson('/api/habitats'), + habitatDetail: (id: string | number) => getJson(`/api/habitats/${id}`), + items: (params: Record) => + getJson(`/api/items${buildQuery(params)}`), + itemDetail: (id: string | number) => getJson(`/api/items/${id}`), + recipes: () => getJson('/api/recipes'), + recipeDetail: (id: string | number) => getJson(`/api/recipes/${id}`) +}; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 0000000..6fba57e --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,301 @@ +:root { + color: #17211b; + background: #f6f4ee; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + margin: 0; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select { + font: inherit; +} + +.app-shell { + min-height: 100vh; +} + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 16px clamp(16px, 4vw, 48px); + border-bottom: 1px solid #d7d2c4; + background: rgba(246, 244, 238, 0.94); + backdrop-filter: blur(12px); +} + +.brand { + font-size: 20px; + font-weight: 800; + color: #1d3b2b; +} + +.nav-tabs { + display: flex; + gap: 8px; + overflow-x: auto; +} + +.nav-tabs a { + min-width: max-content; + padding: 8px 12px; + border-radius: 8px; + color: #4e5c52; + font-weight: 700; +} + +.nav-tabs a.router-link-active { + background: #1f6f50; + color: #ffffff; +} + +.page { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 28px 0 56px; +} + +.page-header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.page-title { + margin: 0; + font-size: clamp(28px, 4vw, 40px); + line-height: 1.1; +} + +.page-subtitle { + margin: 8px 0 0; + color: #657067; +} + +.toolbar { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 12px; + margin-bottom: 20px; + padding: 16px; + border: 1px solid #d7d2c4; + border-radius: 8px; + background: #ffffff; +} + +.field { + display: grid; + gap: 6px; +} + +.field label { + font-size: 12px; + font-weight: 800; + color: #566156; +} + +.field input, +.field select { + width: 100%; + min-height: 40px; + padding: 8px 10px; + border: 1px solid #c7c0b2; + border-radius: 8px; + background: #fffdfa; + color: #17211b; +} + +.segmented { + display: inline-flex; + width: fit-content; + padding: 3px; + border: 1px solid #c7c0b2; + border-radius: 8px; + background: #f1eee5; +} + +.segmented button { + min-width: 52px; + min-height: 32px; + border: 0; + border-radius: 6px; + background: transparent; + color: #566156; + cursor: pointer; +} + +.segmented button.active { + background: #ffffff; + color: #1f6f50; + font-weight: 800; + box-shadow: 0 1px 4px rgba(31, 111, 80, 0.16); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 14px; +} + +.entity-card, +.detail-section { + border: 1px solid #d7d2c4; + border-radius: 8px; + background: #ffffff; +} + +.entity-card { + display: grid; + gap: 12px; + padding: 16px; +} + +.entity-card h2, +.entity-card h3, +.detail-section h2 { + margin: 0; + font-size: 18px; +} + +.meta-line { + margin: 0; + color: #657067; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chip { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 8px; + border: 1px solid #cddfce; + border-radius: 999px; + background: #edf7ef; + color: #1f5c40; + font-size: 13px; + font-weight: 700; +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.detail-section { + padding: 18px; +} + +.detail-section h2 { + margin-bottom: 12px; +} + +.row-list { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.row-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid #ebe6da; +} + +.row-list li:last-child { + border-bottom: 0; +} + +.link-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + width: fit-content; + padding: 7px 12px; + border-radius: 8px; + background: #a83f39; + color: #ffffff; + font-weight: 800; +} + +.status { + padding: 18px; + border: 1px solid #d7d2c4; + border-radius: 8px; + background: #ffffff; + color: #657067; +} + +.tabs { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.tabs button { + min-height: 40px; + padding: 8px 14px; + border: 1px solid #c7c0b2; + border-radius: 8px; + background: #fffdfa; + color: #566156; + cursor: pointer; +} + +.tabs button.active { + border-color: #1f6f50; + background: #1f6f50; + color: #ffffff; + font-weight: 800; +} + +@media (max-width: 760px) { + .topbar { + align-items: start; + flex-direction: column; + } + + .page-header { + align-items: start; + flex-direction: column; + } + + .toolbar, + .detail-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/views/HabitatDetail.vue b/frontend/src/views/HabitatDetail.vue new file mode 100644 index 0000000..72b7033 --- /dev/null +++ b/frontend/src/views/HabitatDetail.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/views/HabitatList.vue b/frontend/src/views/HabitatList.vue new file mode 100644 index 0000000..e084fc7 --- /dev/null +++ b/frontend/src/views/HabitatList.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue new file mode 100644 index 0000000..0129ae3 --- /dev/null +++ b/frontend/src/views/ItemDetail.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/views/ItemsList.vue b/frontend/src/views/ItemsList.vue new file mode 100644 index 0000000..28c1140 --- /dev/null +++ b/frontend/src/views/ItemsList.vue @@ -0,0 +1,101 @@ + + + diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue new file mode 100644 index 0000000..b6600e6 --- /dev/null +++ b/frontend/src/views/PokemonDetail.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/views/PokemonList.vue b/frontend/src/views/PokemonList.vue new file mode 100644 index 0000000..33a4b8f --- /dev/null +++ b/frontend/src/views/PokemonList.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/src/views/RecipeDetail.vue b/frontend/src/views/RecipeDetail.vue new file mode 100644 index 0000000..247fd0b --- /dev/null +++ b/frontend/src/views/RecipeDetail.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..eab4502 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "strict": true, + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..0e5c1ce --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 3000 + } +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..08ab434 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "pokopia", + "private": true, + "packageManager": "pnpm@10.33.2", + "scripts": { + "dev": "pnpm --parallel --filter @pokopia/backend --filter @pokopia/frontend dev", + "lint": "pnpm -r lint", + "typecheck": "pnpm -r typecheck", + "test": "pnpm -r test", + "build": "pnpm -r build" + }, + "engines": { + "node": ">=22" + } +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..507e28e --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - backend + - frontend