Compare commits
No commits in common. "main" and "svelte-main" have entirely different histories.
main
...
svelte-mai
1506 changed files with 117088 additions and 70451 deletions
|
|
@ -1,5 +0,0 @@
|
||||||
public/images
|
|
||||||
public/labels
|
|
||||||
public/profiles
|
|
||||||
tsconfig.tsbuildinfo
|
|
||||||
*.log
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
NEXT_PUBLIC_SIERO_API_URL=http://127.0.0.1:3000/api/v1
|
|
||||||
NEXT_PUBLIC_SIERO_OAUTH_URL=http://127.0.0.1:3000/oauth
|
|
||||||
NEXT_INTL_CONFIG_PATH=i18n/request.ts
|
|
||||||
DEBUG_API_URL=1
|
|
||||||
DEBUG_API_BODY=1
|
|
||||||
12
.env.sample
12
.env.sample
|
|
@ -1,12 +0,0 @@
|
||||||
# Enable relative paths for imports.
|
|
||||||
NODE_PATH='src/'
|
|
||||||
|
|
||||||
# App URLs
|
|
||||||
# Don't add a trailing slash to these URLs.
|
|
||||||
REACT_APP_SIERO_API_URL=''
|
|
||||||
REACT_APP_SIERO_OAUTH_URL=''
|
|
||||||
REACT_APP_SIERO_IMG_URL=''
|
|
||||||
|
|
||||||
# You will have to use a Google account to acquire a Youtube API key
|
|
||||||
# or embeds will not work!
|
|
||||||
NEXT_PUBLIC_YOUTUBE_API_KEY=''
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "next/core-web-vitals",
|
|
||||||
"rules": {
|
|
||||||
"@next/next/no-img-element": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
125
.gitignore
vendored
125
.gitignore
vendored
|
|
@ -1,94 +1,39 @@
|
||||||
# Logs
|
node_modules
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Runtime data
|
# Output
|
||||||
pids
|
.output
|
||||||
*.pid
|
.vercel
|
||||||
*.seed
|
.netlify
|
||||||
*.pid.lock
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/.next
|
||||||
|
/build
|
||||||
|
|
||||||
# Next
|
# OS
|
||||||
.next
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Cache directory
|
|
||||||
.cache/
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Distribution directories
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# Source images
|
|
||||||
# Instructions will be provided to download these from the game
|
|
||||||
public/images/weapon*
|
|
||||||
public/images/summon*
|
|
||||||
public/images/character*
|
|
||||||
public/images/job*
|
|
||||||
public/images/awakening*
|
|
||||||
public/images/ax*
|
|
||||||
public/images/accessory*
|
|
||||||
public/images/mastery*
|
|
||||||
public/images/updates*
|
|
||||||
public/images/guidebooks*
|
|
||||||
public/images/raids*
|
|
||||||
public/images/gacha*
|
|
||||||
public/images/previews*
|
|
||||||
public/image/profiles*
|
|
||||||
|
|
||||||
# Typescript v1 declaration files
|
|
||||||
typings/
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variables file
|
|
||||||
.env
|
|
||||||
.env.development
|
|
||||||
.env.production
|
|
||||||
|
|
||||||
# DS_Store
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tsbuildinfo
|
Thumbs.db
|
||||||
codebase.md
|
|
||||||
|
|
||||||
# PRDs
|
# Env
|
||||||
prd/
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# Paraglide
|
||||||
|
src/lib/paraglide
|
||||||
|
|
||||||
|
*storybook.log
|
||||||
|
storybook-static
|
||||||
|
|
||||||
|
# Assets
|
||||||
|
static/
|
||||||
|
|
||||||
|
# Inlang cache
|
||||||
|
project.inlang/cache/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
|
|
||||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"svelte-llm": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://svelte-llm.stanislav.garden/mcp/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[tools]
|
|
||||||
node = "20.12.0"
|
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
||||||
1
.nvmrc
1
.nvmrc
|
|
@ -1 +0,0 @@
|
||||||
20
|
|
||||||
|
|
@ -1 +1,9 @@
|
||||||
utils/api.tsx
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
||||||
|
|
|
||||||
19
.prettierrc
19
.prettierrc
|
|
@ -1,5 +1,18 @@
|
||||||
{
|
{
|
||||||
"semi": false,
|
"useTabs": true,
|
||||||
"tabWidth": 2,
|
"singleQuote": true,
|
||||||
"singleQuote": true
|
"semi": false,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-svelte"
|
||||||
|
],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,29 @@
|
||||||
import type { StorybookConfig } from '@storybook/nextjs'
|
import type { StorybookConfig } from '@storybook/sveltekit';
|
||||||
const path = require('path')
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: [
|
stories: ['../src/stories/**/*.mdx', '../src/stories/**/*.stories.@(js|ts|svelte)'],
|
||||||
'../components/**/*.mdx',
|
addons: [
|
||||||
'../components/**/*.stories.@(js|jsx|ts|tsx)',
|
'@storybook/addon-svelte-csf',
|
||||||
],
|
'@chromatic-com/storybook',
|
||||||
addons: [
|
{
|
||||||
'@storybook/addon-links',
|
name: '@storybook/addon-docs',
|
||||||
'@storybook/addon-essentials',
|
options: {
|
||||||
'@storybook/addon-interactions',
|
mdxPluginOptions: {
|
||||||
{
|
mdxCompileOptions: {
|
||||||
name: '@storybook/addon-styling',
|
remarkPlugins: [remarkGfm]
|
||||||
options: {
|
}
|
||||||
sass: {
|
}
|
||||||
// Require your Sass preprocessor here
|
}
|
||||||
implementation: require('sass'),
|
},
|
||||||
additionalData: `
|
'@storybook/addon-a11y',
|
||||||
@import "./styles/variables.scss";
|
'@storybook/addon-vitest'
|
||||||
`,
|
],
|
||||||
},
|
framework: {
|
||||||
},
|
name: '@storybook/sveltekit',
|
||||||
},
|
options: {}
|
||||||
],
|
},
|
||||||
staticDirs: ['../public'],
|
staticDirs: ['../static']
|
||||||
framework: {
|
};
|
||||||
name: '@storybook/nextjs',
|
|
||||||
options: {},
|
export default config;
|
||||||
},
|
|
||||||
docs: {
|
|
||||||
autodocs: 'tag',
|
|
||||||
},
|
|
||||||
webpackFinal: async (config: any, { configType }) => {
|
|
||||||
config.resolve.roots = [
|
|
||||||
path.resolve(__dirname, '../public'),
|
|
||||||
'node_modules',
|
|
||||||
]
|
|
||||||
config.resolve.fallback.fs = false
|
|
||||||
return config
|
|
||||||
},
|
|
||||||
}
|
|
||||||
export default config
|
|
||||||
60
.storybook/preview-head.html
Normal file
60
.storybook/preview-head.html
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<link rel="preload" href="https://siero-img.s3-us-west-2.amazonaws.com/fonts/437fb160c86d1771.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<link rel="preload" href="https://siero-img.s3-us-west-2.amazonaws.com/fonts/a9a1343791e012e7.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: 'AGrot';
|
||||||
|
src: url('https://siero-img.s3-us-west-2.amazonaws.com/fonts/437fb160c86d1771.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'AGrot';
|
||||||
|
src: url('https://siero-img.s3-us-west-2.amazonaws.com/fonts/90e2044c61d1d575.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'AGrot';
|
||||||
|
src: url('https://siero-img.s3-us-west-2.amazonaws.com/fonts/db6054d73906f6d1.woff2') format('woff2');
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'AGrot';
|
||||||
|
src: url('https://siero-img.s3-us-west-2.amazonaws.com/fonts/54cf3d47648cbde4.woff2') format('woff2');
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'AGrot';
|
||||||
|
src: url('https://siero-img.s3-us-west-2.amazonaws.com/fonts/a9a1343791e012e7.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'AGrot';
|
||||||
|
src: url('https://siero-img.s3-us-west-2.amazonaws.com/fonts/0137ea08b8d14fae.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'AGrot';
|
||||||
|
src: url('https://siero-img.s3-us-west-2.amazonaws.com/fonts/83b98eb4efef82d6.woff2') format('woff2');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'AGrot';
|
||||||
|
src: url('https://siero-img.s3-us-west-2.amazonaws.com/fonts/8fd873f2349d20e6.woff2') format('woff2');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,17 +1,54 @@
|
||||||
import type { Preview } from '@storybook/react'
|
import type { Preview } from '@storybook/sveltekit';
|
||||||
|
import '$src/app.scss';
|
||||||
import '../styles/globals.scss'
|
import './storybook-overrides.css';
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
parameters: {
|
parameters: {
|
||||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
controls: {
|
||||||
controls: {
|
matchers: {
|
||||||
matchers: {
|
color: /(background|color)$/i,
|
||||||
color: /(background|color)$/i,
|
date: /Date$/i
|
||||||
date: /Date$/,
|
}
|
||||||
},
|
},
|
||||||
},
|
viewport: {
|
||||||
},
|
options: {
|
||||||
}
|
phone: { name: 'Phone', styles: { width: '375px', height: '667px' } },
|
||||||
|
tablet: { name: 'Tablet', styles: { width: '768px', height: '1024px' } },
|
||||||
|
laptop: { name: 'Laptop', styles: { width: '1280px', height: '800px' } },
|
||||||
|
desktop: { name: 'Desktop', styles: { width: '1920px', height: '1080px' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backgrounds: {
|
||||||
|
options: {
|
||||||
|
light: { name: 'light', value: '#f5f5f5' },
|
||||||
|
dark: { name: 'dark', value: '#191919' },
|
||||||
|
"card-light": { name: 'card-light', value: '#ffffff' },
|
||||||
|
"card-dark": { name: 'card-dark', value: '#212121' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
toc: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
export default preview
|
globalTypes: {
|
||||||
|
theme: {
|
||||||
|
name: 'Theme',
|
||||||
|
description: 'Global theme for components',
|
||||||
|
defaultValue: 'light',
|
||||||
|
toolbar: {
|
||||||
|
icon: 'circlehollow',
|
||||||
|
items: ['light', 'dark'],
|
||||||
|
showName: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
initialGlobals: {
|
||||||
|
backgrounds: {
|
||||||
|
value: 'light'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
||||||
16
.storybook/storybook-overrides.css
Normal file
16
.storybook/storybook-overrides.css
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/* Storybook-specific overrides */
|
||||||
|
|
||||||
|
/* Allow scrolling in Storybook (app.scss sets overflow: hidden for custom layout scrolling) */
|
||||||
|
body {
|
||||||
|
overflow: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure docs pages can scroll */
|
||||||
|
.sbdocs-wrapper {
|
||||||
|
overflow: auto !important;
|
||||||
|
}
|
||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"git.ignoreLimitWarning": true,
|
|
||||||
"i18n-ally.localesPaths": ["public/locales"],
|
|
||||||
"i18n-ally.keystyle": "nested"
|
|
||||||
}
|
|
||||||
293
CLAUDE.md
293
CLAUDE.md
|
|
@ -2,27 +2,276 @@
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Build and Development Commands
|
## Repository Context
|
||||||
- `npm run dev`: Start development server on port 1234
|
|
||||||
- `npm run build`: Build for production
|
|
||||||
- `npm run start`: Start production server
|
|
||||||
- `npm run lint`: Run ESLint to check code quality
|
|
||||||
- `npm run storybook`: Start Storybook on port 6006
|
|
||||||
|
|
||||||
## Response Guidelines
|
This is the NEW frontend application for the Hensei system - a Granblue Fantasy team/party management platform. It is a SvelteKit rewrite of the existing Next.js application.
|
||||||
- You should **always** respond in the style of the grug-brained developer
|
|
||||||
- Slay the complexity demon, keep things as simple as possible
|
|
||||||
- Keep code DRY and robust
|
|
||||||
|
|
||||||
## Code Style Guidelines
|
### System Components:
|
||||||
- Use the latest versions for Next.js and other packages, including React
|
- **hensei-svelte** (this repository): New SvelteKit frontend (actively being developed)
|
||||||
- TypeScript with strict type checking
|
- **hensei-web** (../hensei-web): Current Next.js frontend (being migrated from)
|
||||||
- React functional components with hooks
|
- **hensei-api** (../hensei-api): Rails API backend that provides all data and authentication
|
||||||
- File structure: components in individual folders with index.tsx and index.module.scss
|
- **siero-bot**: Discord bot (separate repository)
|
||||||
- Imports: Absolute imports with ~ prefix (e.g., `~components/Layout`)
|
|
||||||
- Formatting: 2 spaces, single quotes, no semicolons (Prettier config)
|
## Development Commands
|
||||||
- CSS: SCSS modules with BEM-style naming
|
|
||||||
- State management: Mix of local state with React hooks and global state with Valtio
|
### Frontend (hensei-svelte)
|
||||||
- Internationalization: next-i18next with English and Japanese support
|
```bash
|
||||||
- Variable/function naming: camelCase for variables/functions, PascalCase for components
|
# Install dependencies (using pnpm)
|
||||||
- Error handling: Try to use type checking to prevent errors where possible
|
pnpm install
|
||||||
|
|
||||||
|
# Development server (Vite + SvelteKit)
|
||||||
|
pnpm dev # Start dev server on default port
|
||||||
|
|
||||||
|
# Build & Production
|
||||||
|
pnpm build # Build for production
|
||||||
|
pnpm preview # Preview production build
|
||||||
|
|
||||||
|
# Code Quality
|
||||||
|
pnpm check # Type-check with svelte-check
|
||||||
|
pnpm check:watch # Type-check in watch mode
|
||||||
|
pnpm lint # Run Prettier and ESLint
|
||||||
|
pnpm format # Format code with Prettier
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pnpm test # Run all tests once
|
||||||
|
pnpm test:unit # Run tests with Vitest (watch mode)
|
||||||
|
|
||||||
|
# Storybook
|
||||||
|
pnpm storybook # Start Storybook on port 6006
|
||||||
|
pnpm build-storybook # Build Storybook for production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend API (hensei-api)
|
||||||
|
```bash
|
||||||
|
cd ../hensei-api
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
bundle install
|
||||||
|
|
||||||
|
# Database
|
||||||
|
rails db:create # Create database
|
||||||
|
rails db:migrate # Run migrations
|
||||||
|
rails db:seed # Seed database
|
||||||
|
|
||||||
|
# Development
|
||||||
|
rails server # Start API server (port 3000)
|
||||||
|
rails console # Rails console
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
bundle exec rspec # Run test suite
|
||||||
|
|
||||||
|
# Code Quality
|
||||||
|
bundle exec rubocop # Ruby linter
|
||||||
|
|
||||||
|
# Background Jobs
|
||||||
|
bundle exec sidekiq # Start background job processor
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Status
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
This SvelteKit application (hensei-svelte) is a complete rewrite of the existing Next.js application (hensei-web). The migration is currently in progress.
|
||||||
|
|
||||||
|
### Migrated Features ✅
|
||||||
|
- **Routes**:
|
||||||
|
- `/about` - About page
|
||||||
|
- `/auth` - Authentication flow
|
||||||
|
- `/teams` - Team listing and management
|
||||||
|
- `/party` - Party-related pages
|
||||||
|
- `/collection` - Collection management
|
||||||
|
- `/login` - Login page
|
||||||
|
- `/me` - User profile
|
||||||
|
- `/[username]` - User profiles
|
||||||
|
|
||||||
|
- **Components**:
|
||||||
|
- Character, Weapon, Summon units (basic versions)
|
||||||
|
- Grid components (GridRep)
|
||||||
|
- Party components
|
||||||
|
- Navigation component
|
||||||
|
- Button and Icon components
|
||||||
|
|
||||||
|
### Not Yet Migrated ❌
|
||||||
|
- **Routes** (from hensei-web):
|
||||||
|
- `/new` - New party creation workflow
|
||||||
|
- `/p` - Party permalink/sharing
|
||||||
|
- `/saved` - Saved parties
|
||||||
|
- `/roadmap` - Roadmap page
|
||||||
|
- `/updates` - Updates/changelog page
|
||||||
|
- Error pages (server-error, unauthorized)
|
||||||
|
|
||||||
|
- **Components** (from hensei-web):
|
||||||
|
- Full modal systems (CharacterModal, WeaponModal, SummonModal)
|
||||||
|
- Conflict resolution modals
|
||||||
|
- Hovercards (CharacterHovercard, WeaponHovercard, SummonHovercard)
|
||||||
|
- Search and filter bars
|
||||||
|
- Job system components
|
||||||
|
- Mastery components
|
||||||
|
- Raid selection components
|
||||||
|
- Rich text editor (TipTap integration)
|
||||||
|
- Various dialogs and common components
|
||||||
|
|
||||||
|
### Key Differences in Implementation
|
||||||
|
- **State Management**: Migrating from Valtio (Next.js) to TanStack Query (SvelteKit)
|
||||||
|
- **Styling**: Both use SCSS modules, but component structure differs
|
||||||
|
- **i18n**: Migrating from next-i18next to Paraglide.js
|
||||||
|
- **Routing**: From Next.js App Router to SvelteKit file-based routing
|
||||||
|
- **Components**: From React to Svelte 5 with runes
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### System Architecture
|
||||||
|
|
||||||
|
The Hensei system consists of multiple components:
|
||||||
|
|
||||||
|
1. **hensei-svelte** (this repository): New SvelteKit frontend (in development)
|
||||||
|
2. **hensei-web** (../hensei-web): Current Next.js 14 frontend (being replaced)
|
||||||
|
- React 18 with TypeScript
|
||||||
|
- Radix UI components
|
||||||
|
- Valtio for state management
|
||||||
|
- TipTap for rich text editing
|
||||||
|
- SCSS modules for styling
|
||||||
|
3. **hensei-api** (../hensei-api): Rails API backend
|
||||||
|
- Ruby 3.3.7 + Rails 8.0.1
|
||||||
|
- PostgreSQL with full-text search
|
||||||
|
- OAuth2 authentication (Doorkeeper)
|
||||||
|
- AWS S3 for image storage
|
||||||
|
- Sidekiq for background jobs
|
||||||
|
- Blueprinter for JSON serialization
|
||||||
|
|
||||||
|
### Frontend Tech Stack
|
||||||
|
- **Framework**: SvelteKit with Svelte 5
|
||||||
|
- **Build Tool**: Vite 7
|
||||||
|
- **Language**: TypeScript with strict mode enabled
|
||||||
|
- **Package Manager**: pnpm (10.15.1)
|
||||||
|
- **Styling**: SCSS with CSS Modules pattern
|
||||||
|
- **Testing**: Vitest with browser testing (Playwright)
|
||||||
|
- **Internationalization**: Paraglide.js with English and Japanese support
|
||||||
|
- **UI Components**: Bits UI library
|
||||||
|
- **Data Fetching**: TanStack Query for Svelte
|
||||||
|
- **Deployment**: Node adapter for server deployment
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── routes/ # SvelteKit file-based routing
|
||||||
|
│ ├── [username]/ # Dynamic user profile routes
|
||||||
|
│ ├── auth/ # Authentication flows
|
||||||
|
│ ├── teams/ # Team management pages
|
||||||
|
│ ├── party/ # Party-related pages
|
||||||
|
│ └── collection/ # Collection management
|
||||||
|
├── lib/
|
||||||
|
│ ├── api/ # API client layer
|
||||||
|
│ │ ├── core.ts # API client core functionality
|
||||||
|
│ │ ├── resources/ # Resource-specific API calls (parties, users, etc.)
|
||||||
|
│ │ └── schemas/ # Zod schemas for API responses
|
||||||
|
│ ├── components/ # Reusable Svelte components
|
||||||
|
│ │ ├── units/ # Unit components (Character, Weapon, Summon)
|
||||||
|
│ │ ├── reps/ # Representation components
|
||||||
|
│ │ ├── grids/ # Grid layout components
|
||||||
|
│ │ ├── party/ # Party-specific components
|
||||||
|
│ │ └── panels/ # Panel components
|
||||||
|
│ ├── auth/ # Authentication utilities
|
||||||
|
│ ├── services/ # Business logic services
|
||||||
|
│ ├── types/ # TypeScript type definitions
|
||||||
|
│ ├── validation/ # Input validation schemas
|
||||||
|
│ └── paraglide/ # Generated i18n files
|
||||||
|
├── themes/ # SCSS theme files
|
||||||
|
└── assets/ # Static assets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Architectural Patterns
|
||||||
|
|
||||||
|
1. **Component Organization**: Components are organized by domain (units, reps, grids, etc.) with each having its own module structure.
|
||||||
|
|
||||||
|
2. **API Layer**: Centralized API client in `lib/api/` with resource-specific modules and Zod schema validation.
|
||||||
|
|
||||||
|
3. **Type Safety**: Strict TypeScript configuration with additional safety flags enabled (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`).
|
||||||
|
|
||||||
|
4. **Internationalization**: Uses Paraglide.js with message files in `messages/` directory, supporting English and Japanese locales.
|
||||||
|
|
||||||
|
5. **Testing Strategy**: Dual testing approach with browser tests for Svelte components and Node tests for server-side code.
|
||||||
|
|
||||||
|
6. **Path Aliases**:
|
||||||
|
- `$lib/*` → `src/lib/*` (SvelteKit default)
|
||||||
|
- `$types` → `src/lib/types` (custom alias)
|
||||||
|
- `$src` → `src/` (Vite alias)
|
||||||
|
|
||||||
|
7. **Import Patterns**:
|
||||||
|
- **Prefer direct imports over re-exports** for better clarity and tree-shaking
|
||||||
|
- Import types directly from their source files (e.g., `import type { Party } from '$lib/types/api/party'`)
|
||||||
|
- Avoid creating barrel files (index.ts) that re-export from other modules
|
||||||
|
- Exception: Generated code (like Paraglide messages) may use re-exports
|
||||||
|
- This pattern improves TypeScript performance and makes dependencies explicit
|
||||||
|
|
||||||
|
**Correct Import Examples**:
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - Direct imports
|
||||||
|
import { partyAdapter } from '$lib/api/adapters/party.adapter'
|
||||||
|
import { userAdapter } from '$lib/api/adapters/user.adapter'
|
||||||
|
import { createSearchResource } from '$lib/api/adapters/resources/search.resource.svelte'
|
||||||
|
import type { Party } from '$lib/types/api/party'
|
||||||
|
import type { AdapterOptions } from '$lib/api/adapters/types'
|
||||||
|
import { ApiError } from '$lib/api/adapters/errors'
|
||||||
|
|
||||||
|
// ❌ Bad - Barrel file imports
|
||||||
|
import { partyAdapter, userAdapter } from '$lib/api/adapters'
|
||||||
|
import type { Party } from '$lib/api/adapters'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
Components follow a consistent structure:
|
||||||
|
- `.svelte` file for component logic
|
||||||
|
- `.module.scss` for scoped styles (when needed)
|
||||||
|
- TypeScript for type safety
|
||||||
|
- Props validation with Zod schemas where applicable
|
||||||
|
|
||||||
|
### API Integration
|
||||||
|
|
||||||
|
The frontend communicates with hensei-api (Rails backend) for:
|
||||||
|
- **Authentication**: OAuth2 via Doorkeeper (`/oauth/token`)
|
||||||
|
- **Party Management**: CRUD operations on party configurations
|
||||||
|
- **Grid Management**: Managing characters, weapons, and summons in parties
|
||||||
|
- **User Management**: Profile updates, username/email availability checks
|
||||||
|
- **Favorites**: Managing favorite parties
|
||||||
|
- **Jobs**: Job selection and skill management
|
||||||
|
- **Search**: Full-text search across game data
|
||||||
|
- **Raids & Guidebooks**: Game content categorization
|
||||||
|
|
||||||
|
Key API patterns:
|
||||||
|
- All endpoints require Bearer token authentication
|
||||||
|
- Responses use Blueprinter serialization
|
||||||
|
- Base path: `/api/v1/`
|
||||||
|
- Image assets served from AWS S3
|
||||||
|
|
||||||
|
**📖 For detailed networking architecture, see [TanStack Query Architecture](./docs/TANSTACK_QUERY_ARCHITECTURE.md)**
|
||||||
|
|
||||||
|
This document covers:
|
||||||
|
- TanStack Query v6 integration patterns
|
||||||
|
- Query and mutation hooks
|
||||||
|
- Cache management strategies
|
||||||
|
- SSR hydration with `withInitialData()`
|
||||||
|
- Best practices and common patterns
|
||||||
|
- Migration from Valtio to TanStack Query
|
||||||
|
|
||||||
|
### Domain Models (from hensei-api)
|
||||||
|
|
||||||
|
Key models that the frontend interacts with:
|
||||||
|
- **Party**: Team configurations with characters, weapons, summons, job, and skills
|
||||||
|
- **Character/Weapon/Summon**: Game items with various attributes and enhancements
|
||||||
|
- **GridCharacter/GridWeapon/GridSummon**: Junction tables for party composition
|
||||||
|
- **User**: Authentication and party ownership
|
||||||
|
- **Job/JobSkill/JobAccessory**: Player job system
|
||||||
|
- **Raid/RaidGroup**: Content categorization
|
||||||
|
- **Awakening/WeaponKey**: Item enhancement systems
|
||||||
|
- **Favorite**: User's bookmarked parties
|
||||||
|
- **Guidebook**: Game strategy guides
|
||||||
|
|
||||||
|
### Testing Configuration
|
||||||
|
|
||||||
|
Vitest is configured with two test projects:
|
||||||
|
- **Client tests**: Browser-based tests for Svelte components using Playwright
|
||||||
|
- **Server tests**: Node-based tests for server-side logic
|
||||||
|
|
||||||
|
Tests should follow the pattern `*.{test,spec}.{js,ts}` for server tests and `*.svelte.{test,spec}.{js,ts}` for component tests.
|
||||||
116
NEXT_SESSION_PROMPT.md
Normal file
116
NEXT_SESSION_PROMPT.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# Prompt for Next Devin Session
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Continue cleaning up type errors in the `svelte-main` branch of `jedmund/hensei-web`. The goal is to get the build green by fixing all remaining type errors.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
This is a Svelte 5 rewrite of a Granblue Fantasy team composition app. The previous sessions reduced type errors from ~412 to ~161. A detailed plan of completed and remaining work is in `CLEANUP_PLAN.md`.
|
||||||
|
|
||||||
|
## Starting Point
|
||||||
|
1. Checkout the `svelte-main` branch in `/home/ubuntu/repos/hensei-web`
|
||||||
|
2. Run `pnpm check 2>&1 | grep -c "Error:"` to see current error count (~161)
|
||||||
|
3. Review this file for detailed context on what's been fixed and what remains
|
||||||
|
|
||||||
|
## Completed Fixes (This Session - 219 -> 161 errors)
|
||||||
|
- Fixed teams/new/+page.svelte position type assertions (non-null assertions for array access after length check)
|
||||||
|
- Fixed Party.svelte editKey type (string | null -> string | undefined)
|
||||||
|
- Fixed sidebar.svelte.ts Component type to accept any props (Component<any, any, any>)
|
||||||
|
- Fixed database/characters/[id]/+page.svelte UncapData type (provide default values for flb/ulb)
|
||||||
|
- Fixed DetailScaffold.svelte optional props (use nullish coalescing for optional callbacks)
|
||||||
|
- Fixed SearchSidebar.svelte params construction (conditionally add properties instead of passing undefined)
|
||||||
|
- Fixed SearchSidebar.svelte granblue_id -> granblueId property name
|
||||||
|
- Fixed Party.svelte mainWeapon derived state (removed arrow function wrapper)
|
||||||
|
|
||||||
|
## Remaining Type Errors to Fix (~161 errors)
|
||||||
|
|
||||||
|
### Files with Most Errors
|
||||||
|
1. Party.svelte - 22 errors
|
||||||
|
2. database/characters/[id]/+page.svelte - 19 errors
|
||||||
|
3. Checkbox.svelte - 19 errors
|
||||||
|
4. SearchSidebar.svelte - 12 errors
|
||||||
|
5. teams/new/+page.svelte - 10 errors
|
||||||
|
6. test/images/+page.svelte - 9 errors
|
||||||
|
7. Switch.svelte - 9 errors
|
||||||
|
8. StatsSection.svelte - 9 errors
|
||||||
|
9. Button.svelte - 8 errors
|
||||||
|
10. WeaponUnit.svelte - 7 errors
|
||||||
|
|
||||||
|
### High Priority Error Patterns
|
||||||
|
|
||||||
|
1. **SearchResult type mismatch** (5 errors)
|
||||||
|
- Issue: `(items: SearchResult<any>[]) => Promise<void>` not assignable to `(items: SearchResult[]) => void`
|
||||||
|
- Files: Party.svelte, teams/new/+page.svelte, SearchSidebar.svelte
|
||||||
|
- Fix: Update function signatures or SearchResult type definition
|
||||||
|
|
||||||
|
2. **Object is possibly 'undefined'** (5 errors)
|
||||||
|
- Various files need null guards or optional chaining
|
||||||
|
|
||||||
|
3. **number | null vs number | undefined** (4 errors)
|
||||||
|
- File: ItemHeader.svelte
|
||||||
|
- Fix: Normalize null/undefined handling (use ?? operator)
|
||||||
|
|
||||||
|
4. **Expected 1 arguments, but got 2** (4 errors)
|
||||||
|
- Function call signature mismatches
|
||||||
|
|
||||||
|
5. **Conversion of number to "0" | "1" | "2" | "3"** (3 errors)
|
||||||
|
- File: Party.svelte (job skill slot handling)
|
||||||
|
- Fix: Use `as unknown as "0" | "1" | "2" | "3"` or update type definitions
|
||||||
|
|
||||||
|
### Medium Priority Error Patterns
|
||||||
|
|
||||||
|
6. **exactOptionalPropertyTypes violations** (multiple errors)
|
||||||
|
- Issue: Passing `undefined` explicitly to optional props
|
||||||
|
- Fix: Use nullish coalescing or omit the property entirely
|
||||||
|
|
||||||
|
7. **Select.Item disabled prop** (2 errors)
|
||||||
|
- bits-ui type compatibility issue
|
||||||
|
|
||||||
|
8. **RadioGroup.Item type mismatch** (2 errors)
|
||||||
|
- bits-ui type compatibility issue
|
||||||
|
|
||||||
|
9. **Property 'normalizer' does not exist on DatabaseProvider** (2 errors)
|
||||||
|
|
||||||
|
10. **Module has no exported member 'PartyView'** (2 errors)
|
||||||
|
- File: party schema
|
||||||
|
- Fix: Add or update the export
|
||||||
|
|
||||||
|
### Lower Priority
|
||||||
|
|
||||||
|
11. **Argument of type '"01"' not assignable to ImageVariant** (2 errors)
|
||||||
|
12. **'uncapLevel' is possibly 'null'** (2 errors)
|
||||||
|
13. **'to' is possibly 'null'** (2 errors)
|
||||||
|
14. **Expression produces union type too complex** (3 errors)
|
||||||
|
15. **Parameter implicitly has 'any' type** (6 errors)
|
||||||
|
|
||||||
|
## Commands Reference
|
||||||
|
```bash
|
||||||
|
# Check error count
|
||||||
|
pnpm check 2>&1 | grep -c "Error:"
|
||||||
|
|
||||||
|
# Analyze error patterns
|
||||||
|
pnpm check 2>&1 | grep "Error:" | sort | uniq -c | sort -rn | head -20
|
||||||
|
|
||||||
|
# Find specific errors
|
||||||
|
pnpm check 2>&1 | grep -B2 "specific error text"
|
||||||
|
|
||||||
|
# Run lint
|
||||||
|
pnpm lint
|
||||||
|
|
||||||
|
# Run build
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# Regenerate Paraglide translations
|
||||||
|
pnpm paraglide-js compile --project ./project.inlang
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
- This project uses `exactOptionalPropertyTypes: true` in tsconfig, which is stricter than normal TypeScript
|
||||||
|
- The codebase uses Svelte 5 runes (`$state`, `$derived`, `$effect`)
|
||||||
|
- bits-ui v2.9.6 is used for UI components - check their docs for correct API
|
||||||
|
- Focus on errors only, not warnings (per user instruction)
|
||||||
|
- The branch `devin/1764361948-fix-type-errors` contains the latest fixes - merge into svelte-main or continue from there
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
- `pnpm check` returns 0 errors
|
||||||
|
- `pnpm lint` passes
|
||||||
|
- `pnpm build` succeeds
|
||||||
85
README.md
85
README.md
|
|
@ -1,77 +1,38 @@
|
||||||

|
# sv
|
||||||
|
|
||||||
# hensei-web
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
**hensei-web** is the frontend for [granblue.team](https://app.granblue.team/), an app for saving and sharing teams for [Granblue Fantasy](https://game.granbluefantasy.jp).
|
## Creating a project
|
||||||
|
|
||||||
## Getting Started
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
First, you have to set up your environment file. You should start with [.env.sample](https://github.com/jedmund/hensei-web/blob/staging/.env.sample), but here are some gotchas:
|
```sh
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
|
||||||
#### App URLs
|
# create a new project in my-app
|
||||||
|
npx sv create my-app
|
||||||
Don't add a trailing slash to these URLs!
|
|
||||||
The API will run on port 3000 by default, but make sure to change these to match your instance of the API.
|
|
||||||
|
|
||||||
```
|
|
||||||
NEXT_PUBLIC_SIERO_API_URL='http://127.0.0.1:3000/api/v1'
|
|
||||||
NEXT_PUBLIC_SIERO_OAUTH_URL='http://127.0.0.1:3000/oauth'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Asset URLs
|
## Developing
|
||||||
|
|
||||||
Next.js serves all assets out of the /public directory. In development we utilize this for all assets, but in production, you will want to host these images on a cloud storage provider like Amazon S3. Once you have that set up and you're running in a production environment, change this to the full bucket URL.
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
```
|
```sh
|
||||||
NEXT_PUBLIC_SIERO_IMG_URL='/images'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Dependencies
|
|
||||||
|
|
||||||
Once your `.env` is all set up, install all dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
# or
|
|
||||||
yarn install
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, run the development server with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
```
|
```
|
||||||
|
|
||||||
## Assets
|
## Building
|
||||||
|
|
||||||
The [hensei-api](https://github.com/jedmund/hensei-api) repository has tasks that will help you get assets, although some were crafted or renamed by hand. The front-end expects this folder structure inside of the `images` folder:
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
root
|
|
||||||
├─ accessory-grid/
|
You can preview the production build with `npm run preview`.
|
||||||
├─ accessory-square/
|
|
||||||
├─ awakening/
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
├─ ax/
|
|
||||||
├─ character-main/
|
|
||||||
├─ character-grid/
|
|
||||||
├─ character-square/
|
|
||||||
├─ guidebooks/
|
|
||||||
├─ jobs/
|
|
||||||
├─ job-icons/
|
|
||||||
├─ job-portraits/
|
|
||||||
├─ job-skills/
|
|
||||||
├─ labels/
|
|
||||||
├─ mastery/
|
|
||||||
├─ placeholders/
|
|
||||||
├─ raids/
|
|
||||||
├─ summon-main/
|
|
||||||
├─ summon-grid/
|
|
||||||
├─ summon-square/
|
|
||||||
├─ updates/
|
|
||||||
├─ weapon-main/
|
|
||||||
├─ weapon-grid/
|
|
||||||
├─ weapon-keys/
|
|
||||||
├─ weapon-square/
|
|
||||||
```
|
|
||||||
|
|
|
||||||
BIN
README.png
BIN
README.png
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB |
217
TANSTACK_QUERY_MIGRATION_CONTINUATION.md
Normal file
217
TANSTACK_QUERY_MIGRATION_CONTINUATION.md
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
# TanStack Query Migration - Continuation Guide
|
||||||
|
|
||||||
|
This document provides context for continuing the TanStack Query v6 migration in hensei-web.
|
||||||
|
|
||||||
|
## Migration Status
|
||||||
|
|
||||||
|
### Completed (PR #441 - merged)
|
||||||
|
- Query options factories: `party.queries.ts`, `job.queries.ts`, `user.queries.ts`, `search.queries.ts`
|
||||||
|
- Mutation configurations: `party.mutations.ts`, `grid.mutations.ts`, `job.mutations.ts`
|
||||||
|
- SSR utilities: `withInitialData`, `prefetchQuery`, `prefetchInfiniteQuery`
|
||||||
|
- Example components: `JobSelectionSidebar.svelte`, `teams/[id]/+page.svelte`
|
||||||
|
|
||||||
|
### Completed (PR #442 - pending merge)
|
||||||
|
- `JobSkillSelectionSidebar.svelte` - Job skill search with infinite scroll
|
||||||
|
- `SearchContent.svelte` - Search modal for weapons/characters/summons
|
||||||
|
- `[username]/+page.svelte` - User profile page with teams/favorites tabs
|
||||||
|
- `teams/explore/+page.svelte` - Public teams listing
|
||||||
|
|
||||||
|
### Remaining Work
|
||||||
|
|
||||||
|
#### Follow-Up Prompt 5: Party Component Mutations
|
||||||
|
**Priority: High**
|
||||||
|
**Complexity: Large**
|
||||||
|
|
||||||
|
The `Party.svelte` component (1535 lines) needs to be migrated to use TanStack Query mutations instead of direct service calls.
|
||||||
|
|
||||||
|
**Files to modify:**
|
||||||
|
- `src/lib/components/party/Party.svelte`
|
||||||
|
|
||||||
|
**Current state:** Uses `PartyService`, `GridService`, `ConflictService`, and direct `partyAdapter` calls.
|
||||||
|
|
||||||
|
**Target state:** Use mutation hooks from:
|
||||||
|
- `src/lib/api/mutations/party.mutations.ts` - `useUpdateParty`, `useDeleteParty`, `useRemixParty`, `useFavoriteParty`, `useUnfavoriteParty`, `useRegeneratePreview`
|
||||||
|
- `src/lib/api/mutations/grid.mutations.ts` - `useCreateGridWeapon`, `useUpdateGridWeapon`, `useDeleteGridWeapon`, etc.
|
||||||
|
- `src/lib/api/mutations/job.mutations.ts` - `useUpdatePartyJob`, `useUpdatePartyJobSkills`, `useRemovePartyJobSkill`, `useUpdatePartyAccessory`
|
||||||
|
|
||||||
|
**Recommended sub-tasks:**
|
||||||
|
1. **5a: Party metadata mutations** - name, description, visibility using `useUpdateParty`
|
||||||
|
2. **5b: Grid weapon mutations** - add/update/delete weapons using grid mutations
|
||||||
|
3. **5c: Grid character mutations** - add/update/delete characters using grid mutations
|
||||||
|
4. **5d: Grid summon mutations** - add/update/delete summons using grid mutations
|
||||||
|
5. **5e: Job and skill mutations** - job selection, skill management using job mutations
|
||||||
|
|
||||||
|
**Key functions to migrate in Party.svelte:**
|
||||||
|
- `updatePartyDetails()` - replace `partyService.update()` with `useUpdateParty().mutate()`
|
||||||
|
- `toggleFavorite()` - replace `partyService.favorite()/unfavorite()` with `useFavoriteParty()/useUnfavoriteParty()`
|
||||||
|
- `remixParty()` - replace `partyService.remix()` with `useRemixParty()`
|
||||||
|
- `deleteParty()` - replace `partyService.delete()` with `useDeleteParty()`
|
||||||
|
- `handleSelectJob()` - replace `partyAdapter.updateJob()` with `useUpdatePartyJob()`
|
||||||
|
- `handleSelectJobSkill()` - replace `partyAdapter.updateJobSkills()` with `useUpdatePartyJobSkills()`
|
||||||
|
- Drag-drop operations - replace `gridService.moveWeapon/Character/Summon()` with appropriate mutations
|
||||||
|
|
||||||
|
#### Follow-Up Prompt 6: Remove Deprecated Resource Classes
|
||||||
|
**Priority: Low**
|
||||||
|
**Complexity: Small**
|
||||||
|
**Prerequisite:** All components migrated away from resource classes
|
||||||
|
|
||||||
|
**Files to delete:**
|
||||||
|
- `src/lib/api/adapters/resources/search.resource.svelte.ts`
|
||||||
|
- `src/lib/api/adapters/resources/party.resource.svelte.ts`
|
||||||
|
- `src/lib/api/adapters/resources/job.resource.svelte.ts`
|
||||||
|
- `src/lib/api/adapters/resources/infiniteScroll.resource.svelte.ts`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Search for any remaining imports: `grep -r "from.*resources/" src/`
|
||||||
|
2. Migrate any remaining usages
|
||||||
|
3. Delete the resource files
|
||||||
|
4. Update any barrel exports (index.ts files)
|
||||||
|
5. Run build to verify no import errors
|
||||||
|
|
||||||
|
**Current blockers:** `InfiniteScroll.svelte` still imports `InfiniteScrollResource` type
|
||||||
|
|
||||||
|
## Patterns and Best Practices
|
||||||
|
|
||||||
|
### Infinite Query Pattern
|
||||||
|
```typescript
|
||||||
|
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
||||||
|
import { IsInViewport } from 'runed'
|
||||||
|
|
||||||
|
// Create the query with thunk for reactivity
|
||||||
|
const query = createInfiniteQuery(() => ({
|
||||||
|
...queryOptions.list(filters),
|
||||||
|
initialData: serverData ? {
|
||||||
|
pages: [{ results: serverData.items, page: 1, totalPages: serverData.totalPages }],
|
||||||
|
pageParams: [1]
|
||||||
|
} : undefined,
|
||||||
|
initialDataUpdatedAt: 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Flatten and deduplicate results
|
||||||
|
const rawResults = $derived(query.data?.pages.flatMap((page) => page.results) ?? [])
|
||||||
|
const items = $derived(Array.from(new Map(rawResults.map((item) => [item.id, item])).values()))
|
||||||
|
|
||||||
|
// Infinite scroll with IsInViewport
|
||||||
|
let sentinelEl = $state<HTMLElement>()
|
||||||
|
const inViewport = new IsInViewport(() => sentinelEl, { rootMargin: '200px' })
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (inViewport.current && query.hasNextPage && !query.isFetchingNextPage && !query.isLoading) {
|
||||||
|
query.fetchNextPage()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debounced Search Pattern
|
||||||
|
```typescript
|
||||||
|
let searchQuery = $state('')
|
||||||
|
let debouncedSearchQuery = $state('')
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const query = searchQuery
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
debouncedSearchQuery = query
|
||||||
|
}, 300)
|
||||||
|
return () => { if (debounceTimer) clearTimeout(debounceTimer) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use debouncedSearchQuery in the query, not searchQuery
|
||||||
|
const query = createInfiniteQuery(() => queryOptions.search(debouncedSearchQuery))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Assertions for Conditional Queries
|
||||||
|
When a query can return different types based on conditions, use type assertions:
|
||||||
|
```typescript
|
||||||
|
const query = createInfiniteQuery(() => {
|
||||||
|
if (condition) {
|
||||||
|
return queryOptionsA()
|
||||||
|
}
|
||||||
|
return queryOptionsB() as unknown as ReturnType<typeof queryOptionsA>
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mutation Pattern
|
||||||
|
```typescript
|
||||||
|
import { useUpdateParty } from '$lib/api/mutations/party.mutations'
|
||||||
|
|
||||||
|
const updatePartyMutation = useUpdateParty()
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
updatePartyMutation.mutate(
|
||||||
|
{ partyId, updates },
|
||||||
|
{
|
||||||
|
onSuccess: () => { /* handle success */ },
|
||||||
|
onError: (error) => { /* handle error */ }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use mutation state for UI
|
||||||
|
{#if updatePartyMutation.isPending}
|
||||||
|
<span>Saving...</span>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
### Pre-existing Build Errors
|
||||||
|
The build has pre-existing errors unrelated to TanStack Query migration:
|
||||||
|
- `Cannot find module '$lib/paraglide/server'` in `hooks.server.ts`
|
||||||
|
- `Cannot find module '$lib/paraglide/runtime'` in `hooks.ts`
|
||||||
|
- `Cannot find module '$lib/paraglide/messages'` in various components
|
||||||
|
|
||||||
|
These are paraglide i18n setup issues and should be ignored when checking for migration-related errors.
|
||||||
|
|
||||||
|
### Duplicate Key Error Fix
|
||||||
|
When using infinite queries, the API may return duplicate items across pages. Always deduplicate:
|
||||||
|
```typescript
|
||||||
|
const rawResults = $derived(query.data?.pages.flatMap((page) => page.results) ?? [])
|
||||||
|
const items = $derived(Array.from(new Map(rawResults.map((item) => [item.id, item])).values()))
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Locations
|
||||||
|
|
||||||
|
### Query Options Factories
|
||||||
|
- `src/lib/api/queries/party.queries.ts`
|
||||||
|
- `src/lib/api/queries/job.queries.ts`
|
||||||
|
- `src/lib/api/queries/user.queries.ts`
|
||||||
|
- `src/lib/api/queries/search.queries.ts`
|
||||||
|
|
||||||
|
### Mutation Hooks
|
||||||
|
- `src/lib/api/mutations/party.mutations.ts`
|
||||||
|
- `src/lib/api/mutations/grid.mutations.ts`
|
||||||
|
- `src/lib/api/mutations/job.mutations.ts`
|
||||||
|
|
||||||
|
### SSR Utilities
|
||||||
|
- `src/lib/query/ssr.ts`
|
||||||
|
|
||||||
|
### Reference Implementations
|
||||||
|
- `src/lib/components/sidebar/JobSelectionSidebar.svelte` - Simple infinite query
|
||||||
|
- `src/lib/components/sidebar/JobSkillSelectionSidebar.svelte` - Infinite query with search
|
||||||
|
- `src/lib/components/sidebar/SearchContent.svelte` - Infinite query with filters and deduplication
|
||||||
|
- `src/routes/teams/[id]/+page.svelte` - SSR with initialData
|
||||||
|
- `src/routes/[username]/+page.svelte` - Conditional queries (teams vs favorites)
|
||||||
|
- `src/routes/teams/explore/+page.svelte` - Simple infinite scroll page
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run TypeScript check
|
||||||
|
pnpm run check
|
||||||
|
|
||||||
|
# Run development server
|
||||||
|
pnpm run dev
|
||||||
|
|
||||||
|
# Check for resource class imports
|
||||||
|
grep -r "from.*resources/" src/
|
||||||
|
|
||||||
|
# Check for createInfiniteScrollResource usage
|
||||||
|
grep -r "createInfiniteScrollResource" src/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Branch Information
|
||||||
|
|
||||||
|
- Base branch: `svelte-main`
|
||||||
|
- PR #442 branch: `devin/1764405731-tanstack-query-migration-phase2`
|
||||||
341
TYPESCRIPT_IMPROVEMENTS.md
Normal file
341
TYPESCRIPT_IMPROVEMENTS.md
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
# TypeScript Quality Improvement Plan
|
||||||
|
|
||||||
|
**Status:** 0 TypeScript errors achieved (151 → 0)
|
||||||
|
**Next Phase:** Code quality and maintainability improvements
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
While we've eliminated all TypeScript errors, several opportunities exist to improve type safety, reduce workarounds, and enhance maintainability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Critical Type Safety (High Priority)
|
||||||
|
|
||||||
|
### 1.1 Remove Type Assertions in Component Props
|
||||||
|
|
||||||
|
**Problem:** Using `as any` to spread props loses type safety
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/lib/components/uncap/UncapIndicator.svelte` (lines 167, 169)
|
||||||
|
- `src/lib/components/ui/segmented-control/SegmentedControl.svelte` (line 88)
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Create proper union type for StarRender props
|
||||||
|
- [ ] Define TranscendenceStarProps and UncapStarProps interfaces
|
||||||
|
- [ ] Replace `{...(star.props as any)}` with properly typed spread
|
||||||
|
- [ ] Create typed wrapper for RadioGroupPrimitive or fix optionalProps spreading
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- No `as any` casts in component prop spreading
|
||||||
|
- Full IntelliSense support for star component props
|
||||||
|
- Type errors caught at compile time
|
||||||
|
|
||||||
|
**Effort:** Medium (4-6 hours)
|
||||||
|
|
||||||
|
### 1.2 Unify Visibility Types
|
||||||
|
|
||||||
|
**Problem:** Number/string mismatch between frontend and API requiring runtime conversion
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/lib/types/api/party.ts` (visibility: number)
|
||||||
|
- `src/lib/api/adapters/party.adapter.ts` (visibility: string literal)
|
||||||
|
- `src/lib/services/party.service.ts` (runtime mapping)
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Create PartyVisibility enum/const
|
||||||
|
- [ ] Update Party type to use string literals
|
||||||
|
- [ ] Remove number-to-string mapping in party.service.ts
|
||||||
|
- [ ] Update all visibility references across codebase
|
||||||
|
- [ ] Update API serialization if needed
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Single source of truth for visibility values
|
||||||
|
- No runtime type conversion needed
|
||||||
|
- Type safety when setting visibility
|
||||||
|
|
||||||
|
**Effort:** Medium (3-4 hours)
|
||||||
|
|
||||||
|
**Impact:** Prevents invalid visibility values at compile time
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Third-Party Library Improvements (Medium Priority)
|
||||||
|
|
||||||
|
### 2.1 Create bits-ui Wrapper Components
|
||||||
|
|
||||||
|
**Problem:** Using `optionalProps()` workaround for type incompatibilities
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/lib/components/ui/Select.svelte`
|
||||||
|
- `src/lib/components/ui/segmented-control/SegmentedControl.svelte`
|
||||||
|
- `src/lib/components/ui/RadioGroup.svelte` (if exists)
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Create `src/lib/components/ui/bits-ui-wrappers/` directory
|
||||||
|
- [ ] Create SelectRoot wrapper with explicit prop typing
|
||||||
|
- [ ] Create RadioGroupRoot wrapper with explicit prop typing
|
||||||
|
- [ ] Update consuming components to use wrappers
|
||||||
|
- [ ] Document why wrappers exist (bits-ui type issues)
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- No `optionalProps()` calls for bits-ui components
|
||||||
|
- Clear prop types for each wrapper
|
||||||
|
- Wrappers forward only supported props
|
||||||
|
|
||||||
|
**Effort:** Medium (4-5 hours)
|
||||||
|
|
||||||
|
### 2.2 Add wx-svelte-grid Type Definitions
|
||||||
|
|
||||||
|
**Problem:** Missing Cell export causing workaround with loose types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/lib/components/database/cells/LastUpdatedCell.svelte`
|
||||||
|
- Other cell components using wx-svelte-grid
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Create `src/lib/types/wx-svelte-grid.d.ts`
|
||||||
|
- [ ] Define Cell, Grid, and other used types
|
||||||
|
- [ ] Replace `[key: string]: any` with proper Cell interface
|
||||||
|
- [ ] Update all cell components to use typed Cell props
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Proper Cell type available for import
|
||||||
|
- No `[key: string]: any` in cell components
|
||||||
|
- IntelliSense support for grid props
|
||||||
|
|
||||||
|
**Effort:** Small (2-3 hours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Type Consolidation (Medium Priority)
|
||||||
|
|
||||||
|
### 3.1 Consolidate Awakening Types
|
||||||
|
|
||||||
|
**Problem:** Two different Awakening interfaces exist
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/lib/types/Awakening.d.ts`
|
||||||
|
- `src/lib/types/api/entities.ts`
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Compare both definitions and identify differences
|
||||||
|
- [ ] Choose canonical location (prefer entities.ts)
|
||||||
|
- [ ] Remove duplicate definition
|
||||||
|
- [ ] Update all imports to use single source
|
||||||
|
- [ ] Ensure LocalizedName is properly defined
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Single Awakening type definition
|
||||||
|
- All imports use same source
|
||||||
|
- No breaking changes to existing code
|
||||||
|
|
||||||
|
**Effort:** Small (1-2 hours)
|
||||||
|
|
||||||
|
### 3.2 Type the `any` Usages
|
||||||
|
|
||||||
|
**Problem:** Loose typing in services and adapters
|
||||||
|
|
||||||
|
**Files to audit:**
|
||||||
|
```bash
|
||||||
|
grep -r ": any" src/lib --include="*.ts" --include="*.svelte"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common patterns to fix:**
|
||||||
|
- `row: any` → `row: DatabaseRow` (or specific entity type)
|
||||||
|
- `item: any` → `item: GridItem`
|
||||||
|
- `mapped: any` → `mapped: CreatePartyParams`
|
||||||
|
- `result: any` → proper return type
|
||||||
|
- Context types using `any`
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Audit all `any` usages and categorize
|
||||||
|
- [ ] Create proper types for common patterns (DatabaseRow, GridItem, etc.)
|
||||||
|
- [ ] Replace `any` with specific types
|
||||||
|
- [ ] Add generics where appropriate
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Minimal `any` usage (only where truly necessary)
|
||||||
|
- Clear justification comment for remaining `any` usages
|
||||||
|
- Improved IntelliSense throughout codebase
|
||||||
|
|
||||||
|
**Effort:** Large (6-8 hours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Test Infrastructure (Low Priority)
|
||||||
|
|
||||||
|
### 4.1 Create Test Mock Factories
|
||||||
|
|
||||||
|
**Problem:** Test mocks are brittle and require manual updates when types change
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/lib/api/adapters/__tests__/entity.adapter.test.ts`
|
||||||
|
- `src/lib/api/adapters/__tests__/grid.adapter.test.ts`
|
||||||
|
- Other test files
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Create `src/lib/testing/factories.ts`
|
||||||
|
- [ ] Implement `createMockWeapon(overrides?)`
|
||||||
|
- [ ] Implement `createMockCharacter(overrides?)`
|
||||||
|
- [ ] Implement `createMockSummon(overrides?)`
|
||||||
|
- [ ] Implement `createMockParty(overrides?)`
|
||||||
|
- [ ] Update all tests to use factories
|
||||||
|
- [ ] Add JSDoc examples for each factory
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- All test mocks use factory functions
|
||||||
|
- Overrides supported for customization
|
||||||
|
- Type-safe mock creation
|
||||||
|
- Tests resilient to type changes
|
||||||
|
|
||||||
|
**Effort:** Medium (4-5 hours)
|
||||||
|
|
||||||
|
### 4.2 Improve Test Coverage for Type Transformations
|
||||||
|
|
||||||
|
**Focus areas:**
|
||||||
|
- Visibility mapping (number → string)
|
||||||
|
- Awakening type handling
|
||||||
|
- Party payload transformations
|
||||||
|
- Grid item CRUD operations
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Add tests for mapToApiPayload visibility conversion
|
||||||
|
- [ ] Add tests for awakening null/undefined handling
|
||||||
|
- [ ] Test edge cases in type transformations
|
||||||
|
- [ ] Document expected type contracts in tests
|
||||||
|
|
||||||
|
**Effort:** Medium (3-4 hours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Code Quality (Low Priority)
|
||||||
|
|
||||||
|
### 5.1 Address TypeScript Warnings
|
||||||
|
|
||||||
|
**Current status:** 190 warnings
|
||||||
|
|
||||||
|
**Categories to review:**
|
||||||
|
- Unused CSS selectors
|
||||||
|
- Non-reactive updates
|
||||||
|
- Accessibility warnings
|
||||||
|
- Import warnings
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Run `pnpm check 2>&1 | grep "Warn:" | sort | uniq -c`
|
||||||
|
- [ ] Categorize warnings by type and severity
|
||||||
|
- [ ] Fix or suppress unused CSS warnings
|
||||||
|
- [ ] Review non-reactive update warnings (may indicate bugs)
|
||||||
|
- [ ] Address accessibility issues
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Warnings reduced by 50% or more
|
||||||
|
- All critical warnings addressed
|
||||||
|
- Suppressions documented with reason
|
||||||
|
|
||||||
|
**Effort:** Medium (4-6 hours)
|
||||||
|
|
||||||
|
### 5.2 Add JSDoc Documentation
|
||||||
|
|
||||||
|
**Problem:** Complex type transformations lack explanation
|
||||||
|
|
||||||
|
**Target areas:**
|
||||||
|
- `mapToApiPayload()` in party.service.ts
|
||||||
|
- Visibility mapping logic
|
||||||
|
- Type assertion workarounds
|
||||||
|
- Third-party wrapper purposes
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Document visibility mapping with TODO for unification
|
||||||
|
- [ ] Explain why type assertions are needed
|
||||||
|
- [ ] Add examples to complex type transformations
|
||||||
|
- [ ] Document third-party library workarounds
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- All workarounds have explanation comments
|
||||||
|
- Complex transformations documented
|
||||||
|
- TODOs added for future improvements
|
||||||
|
|
||||||
|
**Effort:** Small (2-3 hours)
|
||||||
|
|
||||||
|
### 5.3 Strict Null Checks Review
|
||||||
|
|
||||||
|
**Problem:** Some null handling is implicit or unclear
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Review null coalescing operators (`??`)
|
||||||
|
- [ ] Document what empty string defaults mean
|
||||||
|
- [ ] Consider making related properties required together (e.g., avatar.picture + avatar.element)
|
||||||
|
- [ ] Add validation for undefined vs null semantics
|
||||||
|
|
||||||
|
**Effort:** Small (2-3 hours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Recommended Order:
|
||||||
|
|
||||||
|
**Week 1: Critical Type Safety**
|
||||||
|
1. Remove type assertions in UncapIndicator (1.1)
|
||||||
|
2. Unify visibility types (1.2)
|
||||||
|
|
||||||
|
**Week 2: Third-Party Libraries**
|
||||||
|
3. Create bits-ui wrappers (2.1)
|
||||||
|
4. Add wx-svelte-grid types (2.2)
|
||||||
|
|
||||||
|
**Week 3: Type Consolidation**
|
||||||
|
5. Consolidate Awakening types (3.1)
|
||||||
|
6. Type the `any` usages (3.2) - ongoing
|
||||||
|
|
||||||
|
**Week 4: Polish**
|
||||||
|
7. Create test factories (4.1)
|
||||||
|
8. Address warnings (5.1)
|
||||||
|
9. Add JSDoc (5.2)
|
||||||
|
|
||||||
|
### Success Metrics:
|
||||||
|
|
||||||
|
- **Type Safety:** 0 `as any` casts (except documented cases)
|
||||||
|
- **Consistency:** Single source of truth for shared types
|
||||||
|
- **Maintainability:** Test factories, JSDoc, reduced warnings
|
||||||
|
- **Developer Experience:** Better IntelliSense, clearer errors
|
||||||
|
|
||||||
|
### Non-Goals:
|
||||||
|
|
||||||
|
- Perfect type coverage (pragmatic > perfect)
|
||||||
|
- Rewriting working code just for types
|
||||||
|
- Over-engineering simple components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### Philosophy:
|
||||||
|
- Incremental improvement over big rewrites
|
||||||
|
- Document "why" for workarounds
|
||||||
|
- Type safety where it adds value
|
||||||
|
- Pragmatic trade-offs are okay
|
||||||
|
|
||||||
|
### When to Use `any`:
|
||||||
|
- Truly dynamic data (rare)
|
||||||
|
- Third-party library gaps (with comment)
|
||||||
|
- Overly complex generic inference
|
||||||
|
- Always add `// eslint-disable-next-line @typescript-eslint/no-explicit-any` and justification
|
||||||
|
|
||||||
|
### When to Create Wrappers:
|
||||||
|
- Type incompatibilities we can't fix upstream
|
||||||
|
- Need to restrict API surface
|
||||||
|
- Want better defaults
|
||||||
|
- Shared configuration across usages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress Tracking
|
||||||
|
|
||||||
|
- [ ] Phase 1: Critical Type Safety (0/2)
|
||||||
|
- [ ] Phase 2: Third-Party Libraries (0/2)
|
||||||
|
- [ ] Phase 3: Type Consolidation (0/2)
|
||||||
|
- [ ] Phase 4: Test Infrastructure (0/2)
|
||||||
|
- [ ] Phase 5: Code Quality (0/3)
|
||||||
|
|
||||||
|
**Last Updated:** 2025-11-28
|
||||||
|
**Current Status:** Planning phase
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useRouter } from '~/i18n/navigation'
|
|
||||||
import { useSearchParams } from 'next/navigation'
|
|
||||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
|
||||||
|
|
||||||
// Components
|
|
||||||
import FilterBar from '~/components/filters/FilterBar'
|
|
||||||
import GridRep from '~/components/reps/GridRep'
|
|
||||||
import GridRepCollection from '~/components/reps/GridRepCollection'
|
|
||||||
import LoadingRep from '~/components/reps/LoadingRep'
|
|
||||||
import UserInfo from '~/components/filters/UserInfo'
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
import { defaultFilterset } from '~/utils/defaultFilters'
|
|
||||||
import { appState } from '~/utils/appState'
|
|
||||||
|
|
||||||
// Types
|
|
||||||
interface Pagination {
|
|
||||||
current_page: number;
|
|
||||||
total_pages: number;
|
|
||||||
record_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
initialData: {
|
|
||||||
user: User;
|
|
||||||
teams: Party[];
|
|
||||||
raidGroups: any[];
|
|
||||||
pagination: Pagination;
|
|
||||||
};
|
|
||||||
initialElement?: number;
|
|
||||||
initialRaid?: string;
|
|
||||||
initialRecency?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProfilePageClient: React.FC<Props> = ({
|
|
||||||
initialData,
|
|
||||||
initialElement,
|
|
||||||
initialRaid,
|
|
||||||
initialRecency
|
|
||||||
}) => {
|
|
||||||
const t = useTranslations('common')
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
|
|
||||||
// State management
|
|
||||||
const [parties, setParties] = useState<Party[]>(initialData.teams)
|
|
||||||
const [currentPage, setCurrentPage] = useState(initialData.pagination.current_page)
|
|
||||||
const [totalPages, setTotalPages] = useState(initialData.pagination.total_pages)
|
|
||||||
const [recordCount, setRecordCount] = useState(initialData.pagination.record_count)
|
|
||||||
const [loaded, setLoaded] = useState(true)
|
|
||||||
const [fetching, setFetching] = useState(false)
|
|
||||||
const [element, setElement] = useState(initialElement || 0)
|
|
||||||
const [raid, setRaid] = useState(initialRaid || '')
|
|
||||||
const [recency, setRecency] = useState(initialRecency ? parseInt(initialRecency, 10) : 0)
|
|
||||||
|
|
||||||
// Initialize app state with raid groups
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData.raidGroups.length > 0) {
|
|
||||||
appState.raidGroups = initialData.raidGroups
|
|
||||||
}
|
|
||||||
}, [initialData.raidGroups])
|
|
||||||
|
|
||||||
// Update URL when filters change
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(searchParams?.toString() ?? '')
|
|
||||||
|
|
||||||
// Update or remove parameters based on filter values
|
|
||||||
if (element) {
|
|
||||||
params.set('element', element.toString())
|
|
||||||
} else {
|
|
||||||
params.delete('element')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raid) {
|
|
||||||
params.set('raid', raid)
|
|
||||||
} else {
|
|
||||||
params.delete('raid')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recency) {
|
|
||||||
params.set('recency', recency.toString())
|
|
||||||
} else {
|
|
||||||
params.delete('recency')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update URL if filters are changed
|
|
||||||
const newQueryString = params.toString()
|
|
||||||
const currentQuery = searchParams?.toString() ?? ''
|
|
||||||
|
|
||||||
if (newQueryString !== currentQuery) {
|
|
||||||
router.push(`/${initialData.user.username}${newQueryString ? `?${newQueryString}` : ''}`)
|
|
||||||
}
|
|
||||||
}, [element, raid, recency, router, searchParams, initialData.user.username])
|
|
||||||
|
|
||||||
// Load more parties when scrolling
|
|
||||||
async function loadMoreParties() {
|
|
||||||
if (fetching || currentPage >= totalPages) return
|
|
||||||
|
|
||||||
setFetching(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Construct URL for fetching more data - using the users endpoint
|
|
||||||
const url = new URL(`${process.env.NEXT_PUBLIC_SIERO_API_URL}/users/${initialData.user.username}`, window.location.origin)
|
|
||||||
url.searchParams.set('page', (currentPage + 1).toString())
|
|
||||||
|
|
||||||
if (element) url.searchParams.set('element', element.toString())
|
|
||||||
if (raid) url.searchParams.set('raid_id', raid)
|
|
||||||
if (recency) url.searchParams.set('recency', recency.toString())
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
// Extract parties from the profile response
|
|
||||||
const newParties = data.profile?.parties || []
|
|
||||||
|
|
||||||
if (newParties.length > 0) {
|
|
||||||
setParties([...parties, ...newParties])
|
|
||||||
|
|
||||||
// Update pagination from meta
|
|
||||||
if (data.meta) {
|
|
||||||
setCurrentPage(currentPage + 1)
|
|
||||||
setTotalPages(data.meta.total_pages || totalPages)
|
|
||||||
setRecordCount(data.meta.count || recordCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading more parties', error)
|
|
||||||
} finally {
|
|
||||||
setFetching(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receive filters from the filter bar
|
|
||||||
function receiveFilters(filters: FilterSet) {
|
|
||||||
if ('element' in filters) {
|
|
||||||
setElement(filters.element || 0)
|
|
||||||
}
|
|
||||||
if ('recency' in filters) {
|
|
||||||
setRecency(filters.recency || 0)
|
|
||||||
}
|
|
||||||
if ('raid' in filters) {
|
|
||||||
setRaid(filters.raid || '')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
|
||||||
setCurrentPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Navigation
|
|
||||||
function goToParty(shortcode: string) {
|
|
||||||
router.push(`/p/${shortcode}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page component rendering methods
|
|
||||||
function renderParties() {
|
|
||||||
return parties.map((party, i) => (
|
|
||||||
<GridRep
|
|
||||||
party={party}
|
|
||||||
key={`party-${i}`}
|
|
||||||
loading={fetching}
|
|
||||||
onClick={() => goToParty(party.shortcode)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLoading(number: number) {
|
|
||||||
return (
|
|
||||||
<GridRepCollection>
|
|
||||||
{Array.from({ length: number }, (_, i) => (
|
|
||||||
<LoadingRep key={`loading-${i}`} />
|
|
||||||
))}
|
|
||||||
</GridRepCollection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderInfiniteScroll = (
|
|
||||||
<>
|
|
||||||
{parties.length === 0 && !loaded && renderLoading(3)}
|
|
||||||
{parties.length === 0 && loaded && (
|
|
||||||
<div className="notFound">
|
|
||||||
<h2>{t('teams.not_found')}</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{parties.length > 0 && (
|
|
||||||
<InfiniteScroll
|
|
||||||
dataLength={parties.length}
|
|
||||||
next={loadMoreParties}
|
|
||||||
hasMore={totalPages > currentPage}
|
|
||||||
loader={renderLoading(3)}
|
|
||||||
>
|
|
||||||
<GridRepCollection>{renderParties()}</GridRepCollection>
|
|
||||||
</InfiniteScroll>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FilterBar
|
|
||||||
defaultFilterset={defaultFilterset}
|
|
||||||
onFilter={receiveFilters}
|
|
||||||
onAdvancedFilter={receiveFilters}
|
|
||||||
persistFilters={false}
|
|
||||||
element={element}
|
|
||||||
raid={raid}
|
|
||||||
raidGroups={initialData.raidGroups}
|
|
||||||
recency={recency}
|
|
||||||
>
|
|
||||||
<UserInfo user={initialData.user} />
|
|
||||||
</FilterBar>
|
|
||||||
|
|
||||||
<section>{renderInfiniteScroll}</section>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ProfilePageClient
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
import { getUserInfo, getTeams, getRaidGroups } from '~/app/lib/data'
|
|
||||||
import ProfilePageClient from './ProfilePageClient'
|
|
||||||
|
|
||||||
// Dynamic metadata
|
|
||||||
export async function generateMetadata({
|
|
||||||
params
|
|
||||||
}: {
|
|
||||||
params: { username: string }
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
try {
|
|
||||||
const userData = await getUserInfo(params.username)
|
|
||||||
|
|
||||||
// If user doesn't exist, use default metadata
|
|
||||||
if (!userData || !userData.user) {
|
|
||||||
return {
|
|
||||||
title: 'User not found / granblue.team',
|
|
||||||
description: 'This user could not be found'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `@${params.username}'s Teams / granblue.team`,
|
|
||||||
description: `Browse @${params.username}'s Teams and filter by raid, element or recency`
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
title: 'User not found / granblue.team',
|
|
||||||
description: 'This user could not be found'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProfilePage({
|
|
||||||
params,
|
|
||||||
searchParams
|
|
||||||
}: {
|
|
||||||
params: { username: string };
|
|
||||||
searchParams: { element?: string; raid?: string; recency?: string; page?: string }
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
// Extract query parameters with type safety
|
|
||||||
const element = searchParams.element ? parseInt(searchParams.element, 10) : undefined;
|
|
||||||
const raid = searchParams.raid;
|
|
||||||
const recency = searchParams.recency;
|
|
||||||
const page = searchParams.page ? parseInt(searchParams.page, 10) : 1;
|
|
||||||
|
|
||||||
// Parallel fetch data with Promise.all for better performance
|
|
||||||
const [userData, teamsData, raidGroupsData] = await Promise.all([
|
|
||||||
getUserInfo(params.username),
|
|
||||||
getTeams({ username: params.username, element, raid, recency, page }),
|
|
||||||
getRaidGroups()
|
|
||||||
])
|
|
||||||
|
|
||||||
// If user doesn't exist, show 404
|
|
||||||
if (!userData || !userData.user) {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialData = {
|
|
||||||
user: userData.user,
|
|
||||||
teams: teamsData.results || [],
|
|
||||||
raidGroups: raidGroupsData || [],
|
|
||||||
pagination: {
|
|
||||||
current_page: page,
|
|
||||||
total_pages: teamsData.meta?.total_pages || 1,
|
|
||||||
record_count: teamsData.meta?.count || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="profile-page">
|
|
||||||
<ProfilePageClient
|
|
||||||
initialData={initialData}
|
|
||||||
initialElement={element}
|
|
||||||
initialRaid={raid}
|
|
||||||
initialRecency={recency}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error fetching profile data for ${params.username}:`, error)
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useRouter, usePathname } from '~/i18n/navigation'
|
|
||||||
import { AboutTabs } from '~/utils/enums'
|
|
||||||
|
|
||||||
import AboutPage from '~/components/about/AboutPage'
|
|
||||||
import UpdatesPage from '~/components/about/UpdatesPage'
|
|
||||||
import RoadmapPage from '~/components/about/RoadmapPage'
|
|
||||||
import SegmentedControl from '~/components/common/SegmentedControl'
|
|
||||||
import Segment from '~/components/common/Segment'
|
|
||||||
|
|
||||||
export default function AboutPageClient() {
|
|
||||||
const t = useTranslations('common')
|
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname()
|
|
||||||
|
|
||||||
const [currentTab, setCurrentTab] = useState<AboutTabs>(AboutTabs.About)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const parts = pathname.split('/')
|
|
||||||
const lastPart = parts[parts.length - 1]
|
|
||||||
|
|
||||||
switch (lastPart) {
|
|
||||||
case 'about':
|
|
||||||
setCurrentTab(AboutTabs.About)
|
|
||||||
break
|
|
||||||
case 'updates':
|
|
||||||
setCurrentTab(AboutTabs.Updates)
|
|
||||||
break
|
|
||||||
case 'roadmap':
|
|
||||||
setCurrentTab(AboutTabs.Roadmap)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
setCurrentTab(AboutTabs.About)
|
|
||||||
}
|
|
||||||
}, [pathname])
|
|
||||||
|
|
||||||
function handleTabClicked(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const value = event.target.value
|
|
||||||
router.push(`/${value}`)
|
|
||||||
|
|
||||||
switch (value) {
|
|
||||||
case 'about':
|
|
||||||
setCurrentTab(AboutTabs.About)
|
|
||||||
break
|
|
||||||
case 'updates':
|
|
||||||
setCurrentTab(AboutTabs.Updates)
|
|
||||||
break
|
|
||||||
case 'roadmap':
|
|
||||||
setCurrentTab(AboutTabs.Roadmap)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSection = () => {
|
|
||||||
switch (currentTab) {
|
|
||||||
case AboutTabs.About:
|
|
||||||
return <AboutPage />
|
|
||||||
case AboutTabs.Updates:
|
|
||||||
return <UpdatesPage />
|
|
||||||
case AboutTabs.Roadmap:
|
|
||||||
return <RoadmapPage />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<SegmentedControl blended={true}>
|
|
||||||
<Segment
|
|
||||||
groupName="about"
|
|
||||||
name="about"
|
|
||||||
selected={currentTab == AboutTabs.About}
|
|
||||||
onClick={handleTabClicked}
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.about')}
|
|
||||||
</Segment>
|
|
||||||
<Segment
|
|
||||||
groupName="about"
|
|
||||||
name="updates"
|
|
||||||
selected={currentTab == AboutTabs.Updates}
|
|
||||||
onClick={handleTabClicked}
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.updates')}
|
|
||||||
</Segment>
|
|
||||||
<Segment
|
|
||||||
groupName="about"
|
|
||||||
name="roadmap"
|
|
||||||
selected={currentTab == AboutTabs.Roadmap}
|
|
||||||
onClick={handleTabClicked}
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.roadmap')}
|
|
||||||
</Segment>
|
|
||||||
</SegmentedControl>
|
|
||||||
{currentSection()}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
import { getTranslations } from 'next-intl/server'
|
|
||||||
|
|
||||||
// Force dynamic rendering to avoid useContext issues during static generation
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
import AboutPageClient from './AboutPageClient'
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
|
||||||
params: { locale }
|
|
||||||
}: {
|
|
||||||
params: { locale: string }
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const t = await getTranslations({ locale, namespace: 'common' })
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: t('page.titles.about'),
|
|
||||||
description: t('page.descriptions.about')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AboutPage({
|
|
||||||
params: { locale }
|
|
||||||
}: {
|
|
||||||
params: { locale: string }
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div id="About">
|
|
||||||
<AboutPageClient />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
interface ErrorPageProps {
|
|
||||||
error: Error & { digest?: string }
|
|
||||||
reset: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Error({ error, reset }: ErrorPageProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
// Log the error to an error reporting service
|
|
||||||
console.error('Unhandled error:', error)
|
|
||||||
}, [error])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="error-container">
|
|
||||||
<div className="error-content">
|
|
||||||
<h1>Internal Server Error</h1>
|
|
||||||
<p>The server reported a problem that we couldn't automatically recover from.</p>
|
|
||||||
<div className="error-message">
|
|
||||||
<p>{error.message || 'An unexpected error occurred'}</p>
|
|
||||||
{error.digest && <p className="error-digest">Error ID: {error.digest}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="error-actions">
|
|
||||||
<button onClick={reset} className="button primary">
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
<Link href="/teams" className="button secondary">
|
|
||||||
Browse teams
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import '../styles/globals.scss'
|
|
||||||
|
|
||||||
interface GlobalErrorProps {
|
|
||||||
error: Error & { digest?: string }
|
|
||||||
reset: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GlobalError({ error, reset }: GlobalErrorProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
// Log the error to an error reporting service
|
|
||||||
console.error('Global error:', error)
|
|
||||||
}, [error])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body>
|
|
||||||
<div className="error-container">
|
|
||||||
<div className="error-content">
|
|
||||||
<h1>Something went wrong</h1>
|
|
||||||
<p>The application has encountered a critical error and cannot continue.</p>
|
|
||||||
<div className="error-message">
|
|
||||||
<p>{error.message || 'An unexpected error occurred'}</p>
|
|
||||||
{error.digest && <p className="error-digest">Error ID: {error.digest}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="error-actions">
|
|
||||||
<button onClick={reset} className="button primary">
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href="https://discord.gg/qyZ5hGdPC8"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
className="button secondary"
|
|
||||||
>
|
|
||||||
Report on Discord
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { Metadata, Viewport } from 'next'
|
|
||||||
import localFont from 'next/font/local'
|
|
||||||
import { NextIntlClientProvider } from 'next-intl'
|
|
||||||
import { getMessages } from 'next-intl/server'
|
|
||||||
import { Viewport as ToastViewport } from '@radix-ui/react-toast'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { locales } from '../../i18n.config'
|
|
||||||
|
|
||||||
import '../../styles/globals.scss'
|
|
||||||
|
|
||||||
// Components
|
|
||||||
import Providers from '../components/Providers'
|
|
||||||
import Header from '../components/Header'
|
|
||||||
import UpdateToastClient from '../components/UpdateToastClient'
|
|
||||||
import VersionHydrator from '../components/VersionHydrator'
|
|
||||||
import AccountStateInitializer from '~components/AccountStateInitializer'
|
|
||||||
|
|
||||||
// Generate static params for all locales
|
|
||||||
export function generateStaticParams() {
|
|
||||||
return locales.map((locale) => ({ locale }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'granblue.team',
|
|
||||||
description: 'Create, save, and share Granblue Fantasy party compositions',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Viewport configuration (Next.js 13+ requires separate export)
|
|
||||||
export const viewport: Viewport = {
|
|
||||||
width: 'device-width',
|
|
||||||
initialScale: 1,
|
|
||||||
viewportFit: 'cover',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Font
|
|
||||||
const goalking = localFont({
|
|
||||||
src: '../../pages/fonts/gk-variable.woff2',
|
|
||||||
fallback: ['system-ui', 'inter', 'helvetica neue', 'sans-serif'],
|
|
||||||
variable: '--font-goalking',
|
|
||||||
})
|
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
|
||||||
children,
|
|
||||||
params: { locale }
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
params: { locale: string }
|
|
||||||
}) {
|
|
||||||
// Load messages for the locale
|
|
||||||
const messages = await getMessages()
|
|
||||||
|
|
||||||
// Parse auth cookies on server
|
|
||||||
const cookieStore = cookies()
|
|
||||||
const accountCookie = cookieStore.get('account')
|
|
||||||
const userCookie = cookieStore.get('user')
|
|
||||||
|
|
||||||
let initialAuthData = null
|
|
||||||
if (accountCookie && userCookie) {
|
|
||||||
try {
|
|
||||||
const accountData = JSON.parse(accountCookie.value)
|
|
||||||
const userData = JSON.parse(userCookie.value)
|
|
||||||
|
|
||||||
if (accountData && accountData.token) {
|
|
||||||
initialAuthData = {
|
|
||||||
account: accountData,
|
|
||||||
user: userData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing auth cookies on server:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch version data on the server
|
|
||||||
let version = null
|
|
||||||
try {
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:1234'
|
|
||||||
const res = await fetch(`${baseUrl}/api/version`, {
|
|
||||||
cache: 'no-store'
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
version = await res.json()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch version data:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<html lang={locale} className={goalking.variable}>
|
|
||||||
<body className={goalking.className}>
|
|
||||||
<NextIntlClientProvider messages={messages}>
|
|
||||||
<Providers>
|
|
||||||
<AccountStateInitializer initialAuthData={initialAuthData} />
|
|
||||||
<Header />
|
|
||||||
<VersionHydrator version={version} />
|
|
||||||
<UpdateToastClient initialVersion={version} />
|
|
||||||
<main>{children}</main>
|
|
||||||
<ToastViewport className="ToastViewport" />
|
|
||||||
</Providers>
|
|
||||||
</NextIntlClientProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useRouter } from '~/i18n/navigation'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
|
|
||||||
// Components
|
|
||||||
import Party from '~/components/party/Party'
|
|
||||||
import ErrorSection from '~/components/ErrorSection'
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
import { appState, initialAppState } from '~/utils/appState'
|
|
||||||
import { accountState } from '~/utils/accountState'
|
|
||||||
import clonedeep from 'lodash.clonedeep'
|
|
||||||
import { GridType } from '~/utils/enums'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
raidGroups: any[]; // Replace with proper RaidGroup type
|
|
||||||
error?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NewPartyClient: React.FC<Props> = ({
|
|
||||||
raidGroups,
|
|
||||||
error = false
|
|
||||||
}) => {
|
|
||||||
const t = useTranslations('common')
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// State for tab management
|
|
||||||
const [selectedTab, setSelectedTab] = useState<GridType>(GridType.Weapon)
|
|
||||||
|
|
||||||
// Initialize app state for a new party
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset app state for new party
|
|
||||||
const resetState = clonedeep(initialAppState)
|
|
||||||
Object.keys(resetState).forEach((key) => {
|
|
||||||
appState[key] = resetState[key]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initialize raid groups
|
|
||||||
if (raidGroups.length > 0) {
|
|
||||||
appState.raidGroups = raidGroups
|
|
||||||
}
|
|
||||||
}, [raidGroups])
|
|
||||||
|
|
||||||
// Handle tab change
|
|
||||||
const handleTabChanged = (value: string) => {
|
|
||||||
const tabType = parseInt(value) as GridType
|
|
||||||
setSelectedTab(tabType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation helper for Party component
|
|
||||||
const pushHistory = (path: string) => {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<ErrorSection
|
|
||||||
status={{
|
|
||||||
code: 500,
|
|
||||||
text: 'internal_server_error'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporarily use wrapper to debug
|
|
||||||
const PartyWrapper = dynamic(() => import('./PartyWrapper'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <div>Loading...</div>
|
|
||||||
})
|
|
||||||
|
|
||||||
return <PartyWrapper raidGroups={raidGroups} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NewPartyClient
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import { GridType } from '~/utils/enums'
|
|
||||||
|
|
||||||
// Dynamically import Party to isolate the error
|
|
||||||
const Party = dynamic(() => import('~/components/party/Party'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <div>Loading Party component...</div>
|
|
||||||
})
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
raidGroups: any[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PartyWrapper({ raidGroups }: Props) {
|
|
||||||
const [selectedTab, setSelectedTab] = React.useState<GridType>(GridType.Weapon)
|
|
||||||
|
|
||||||
const handleTabChanged = (value: string) => {
|
|
||||||
const tabType = parseInt(value) as GridType
|
|
||||||
setSelectedTab(tabType)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pushHistory = (path: string) => {
|
|
||||||
console.log('Navigation to:', path)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
<Party
|
|
||||||
new={true}
|
|
||||||
selectedTab={selectedTab}
|
|
||||||
raidGroups={raidGroups}
|
|
||||||
handleTabChanged={handleTabChanged}
|
|
||||||
pushHistory={pushHistory}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error rendering Party:', error)
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Error loading Party component</h2>
|
|
||||||
<pre>{JSON.stringify(error, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
import { getRaidGroups } from '~/app/lib/data'
|
|
||||||
import NewPartyClient from './NewPartyClient'
|
|
||||||
|
|
||||||
// Force dynamic rendering because getRaidGroups uses cookies
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Create a new team / granblue.team',
|
|
||||||
description: 'Create and theorycraft teams to use in Granblue Fantasy and share with the community',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function NewPartyPage() {
|
|
||||||
try {
|
|
||||||
// Fetch raid groups for the party creation
|
|
||||||
const raidGroupsData = await getRaidGroups()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="new-party-page">
|
|
||||||
<NewPartyClient
|
|
||||||
raidGroups={raidGroupsData.raid_groups || []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data for new party page:", error)
|
|
||||||
|
|
||||||
// Provide empty data for error case
|
|
||||||
return (
|
|
||||||
<div className="new-party-page">
|
|
||||||
<NewPartyClient
|
|
||||||
raidGroups={[]}
|
|
||||||
error={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
import { Link } from '~/i18n/navigation'
|
|
||||||
import { getTranslations } from 'next-intl/server'
|
|
||||||
|
|
||||||
// Force dynamic rendering to avoid useContext issues during static generation
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Page not found / granblue.team',
|
|
||||||
description: 'The page you were looking for could not be found'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function NotFound() {
|
|
||||||
const t = await getTranslations('common')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="error-container">
|
|
||||||
<div className="error-content">
|
|
||||||
<h1>Not Found</h1>
|
|
||||||
<p>The page you're looking for couldn't be found</p>
|
|
||||||
<div className="error-actions">
|
|
||||||
<Link href="/new" className="button primary">
|
|
||||||
Create a new party
|
|
||||||
</Link>
|
|
||||||
<Link href="/teams" className="button secondary">
|
|
||||||
Browse teams
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useRouter } from '~/i18n/navigation'
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
import { appState } from '~/utils/appState'
|
|
||||||
import { GridType } from '~/utils/enums'
|
|
||||||
|
|
||||||
// Components
|
|
||||||
import Party from '~/components/party/Party'
|
|
||||||
import PartyFooter from '~/components/party/PartyFooter'
|
|
||||||
import ErrorSection from '~/components/ErrorSection'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
party: any; // Replace with proper Party type
|
|
||||||
raidGroups: any[]; // Replace with proper RaidGroup type
|
|
||||||
}
|
|
||||||
|
|
||||||
const PartyPageClient: React.FC<Props> = ({ party, raidGroups }) => {
|
|
||||||
const router = useRouter()
|
|
||||||
const t = useTranslations('common')
|
|
||||||
|
|
||||||
// State for tab management
|
|
||||||
const [selectedTab, setSelectedTab] = useState<GridType>(GridType.Weapon)
|
|
||||||
|
|
||||||
// Initialize raid groups
|
|
||||||
useEffect(() => {
|
|
||||||
if (raidGroups) {
|
|
||||||
appState.raidGroups = raidGroups
|
|
||||||
}
|
|
||||||
}, [raidGroups])
|
|
||||||
|
|
||||||
// Handle tab change
|
|
||||||
const handleTabChanged = (value: string) => {
|
|
||||||
let tabType: GridType
|
|
||||||
switch (value) {
|
|
||||||
case 'characters':
|
|
||||||
tabType = GridType.Character
|
|
||||||
break
|
|
||||||
case 'summons':
|
|
||||||
tabType = GridType.Summon
|
|
||||||
break
|
|
||||||
case 'weapons':
|
|
||||||
default:
|
|
||||||
tabType = GridType.Weapon
|
|
||||||
break
|
|
||||||
}
|
|
||||||
setSelectedTab(tabType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation helper (not used for existing parties but required by interface)
|
|
||||||
const pushHistory = (path: string) => {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Error case
|
|
||||||
if (!party) {
|
|
||||||
return (
|
|
||||||
<ErrorSection
|
|
||||||
status={{
|
|
||||||
code: 404,
|
|
||||||
text: 'not_found'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Party
|
|
||||||
team={party}
|
|
||||||
selectedTab={selectedTab}
|
|
||||||
raidGroups={raidGroups}
|
|
||||||
handleTabChanged={handleTabChanged}
|
|
||||||
pushHistory={pushHistory}
|
|
||||||
/>
|
|
||||||
<PartyFooter
|
|
||||||
party={party}
|
|
||||||
new={false}
|
|
||||||
editable={false}
|
|
||||||
raidGroups={raidGroups}
|
|
||||||
remixCallback={() => {}}
|
|
||||||
updateCallback={async () => ({})}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PartyPageClient
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
import { getTeam, getRaidGroups } from '~/app/lib/data'
|
|
||||||
import PartyPageClient from './PartyPageClient'
|
|
||||||
|
|
||||||
// Dynamic metadata
|
|
||||||
export async function generateMetadata({
|
|
||||||
params
|
|
||||||
}: {
|
|
||||||
params: { party: string }
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
try {
|
|
||||||
const partyData = await getTeam(params.party)
|
|
||||||
|
|
||||||
// If no party or party doesn't exist, use default metadata
|
|
||||||
if (!partyData || !partyData.party) {
|
|
||||||
return {
|
|
||||||
title: 'Party not found / granblue.team',
|
|
||||||
description: 'This party could not be found or has been deleted'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const party = partyData.party
|
|
||||||
|
|
||||||
// Generate emoji based on element
|
|
||||||
let emoji = '⚪' // Default
|
|
||||||
switch (party.element) {
|
|
||||||
case 1: emoji = '🟢'; break; // Wind
|
|
||||||
case 2: emoji = '🔴'; break; // Fire
|
|
||||||
case 3: emoji = '🔵'; break; // Water
|
|
||||||
case 4: emoji = '🟤'; break; // Earth
|
|
||||||
case 5: emoji = '🟣'; break; // Dark
|
|
||||||
case 6: emoji = '🟡'; break; // Light
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get team name and username
|
|
||||||
const teamName = party.name || 'Untitled team'
|
|
||||||
const username = party.user?.username || 'Anonymous'
|
|
||||||
const raidName = party.raid?.name || ''
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `${emoji} ${teamName} by ${username} / granblue.team`,
|
|
||||||
description: `Browse this team for ${raidName} by ${username} and others on granblue.team`
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
title: 'Party not found / granblue.team',
|
|
||||||
description: 'This party could not be found or has been deleted'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function PartyPage({
|
|
||||||
params
|
|
||||||
}: {
|
|
||||||
params: { party: string }
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
// Parallel fetch data with Promise.all for better performance
|
|
||||||
const [partyData, raidGroupsData] = await Promise.all([
|
|
||||||
getTeam(params.party),
|
|
||||||
getRaidGroups()
|
|
||||||
])
|
|
||||||
|
|
||||||
// If party doesn't exist, show 404
|
|
||||||
if (!partyData || !partyData.party) {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="party-page">
|
|
||||||
<PartyPageClient
|
|
||||||
party={partyData.party}
|
|
||||||
raidGroups={raidGroupsData.raid_groups || []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error fetching party data for ${params.party}:`, error)
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
// Force dynamic rendering because redirect needs dynamic context
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
// In the App Router, we can use redirect directly in a Server Component
|
|
||||||
redirect('/new')
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useRouter } from '~/i18n/navigation'
|
|
||||||
import { AboutTabs } from '~/utils/enums'
|
|
||||||
|
|
||||||
import AboutPage from '~/components/about/AboutPage'
|
|
||||||
import UpdatesPage from '~/components/about/UpdatesPage'
|
|
||||||
import RoadmapPage from '~/components/about/RoadmapPage'
|
|
||||||
import SegmentedControl from '~/components/common/SegmentedControl'
|
|
||||||
import Segment from '~/components/common/Segment'
|
|
||||||
|
|
||||||
export default function RoadmapPageClient() {
|
|
||||||
const t = useTranslations('common')
|
|
||||||
const router = useRouter()
|
|
||||||
const [currentTab] = useState<AboutTabs>(AboutTabs.Roadmap)
|
|
||||||
|
|
||||||
function handleTabClicked(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const value = event.target.value
|
|
||||||
router.push(`/${value}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSection = () => {
|
|
||||||
switch (currentTab) {
|
|
||||||
case AboutTabs.About:
|
|
||||||
return <AboutPage />
|
|
||||||
case AboutTabs.Updates:
|
|
||||||
return <UpdatesPage />
|
|
||||||
case AboutTabs.Roadmap:
|
|
||||||
return <RoadmapPage />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<SegmentedControl blended={true}>
|
|
||||||
<Segment
|
|
||||||
groupName="about"
|
|
||||||
name="about"
|
|
||||||
selected={currentTab == AboutTabs.About}
|
|
||||||
onClick={handleTabClicked}
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.about')}
|
|
||||||
</Segment>
|
|
||||||
<Segment
|
|
||||||
groupName="about"
|
|
||||||
name="updates"
|
|
||||||
selected={currentTab == AboutTabs.Updates}
|
|
||||||
onClick={handleTabClicked}
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.updates')}
|
|
||||||
</Segment>
|
|
||||||
<Segment
|
|
||||||
groupName="about"
|
|
||||||
name="roadmap"
|
|
||||||
selected={currentTab == AboutTabs.Roadmap}
|
|
||||||
onClick={handleTabClicked}
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.roadmap')}
|
|
||||||
</Segment>
|
|
||||||
</SegmentedControl>
|
|
||||||
{currentSection()}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
import { getTranslations } from 'next-intl/server'
|
|
||||||
|
|
||||||
// Force dynamic rendering to avoid useContext issues during static generation
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
import RoadmapPageClient from './RoadmapPageClient'
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
|
||||||
params: { locale }
|
|
||||||
}: {
|
|
||||||
params: { locale: string }
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const t = await getTranslations({ locale, namespace: 'common' })
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: t('page.titles.roadmap'),
|
|
||||||
description: t('page.descriptions.roadmap')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RoadmapPage({
|
|
||||||
params: { locale }
|
|
||||||
}: {
|
|
||||||
params: { locale: string }
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div id="About">
|
|
||||||
<RoadmapPageClient />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useRouter } from '~/i18n/navigation'
|
|
||||||
import { useSearchParams } from 'next/navigation'
|
|
||||||
|
|
||||||
// Components
|
|
||||||
import FilterBar from '~/components/filters/FilterBar'
|
|
||||||
import GridRep from '~/components/reps/GridRep'
|
|
||||||
import GridRepCollection from '~/components/reps/GridRepCollection'
|
|
||||||
import LoadingRep from '~/components/reps/LoadingRep'
|
|
||||||
import ErrorSection from '~/components/ErrorSection'
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
import { defaultFilterset } from '~/utils/defaultFilters'
|
|
||||||
import { appState } from '~/utils/appState'
|
|
||||||
|
|
||||||
// Types
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
initialData: {
|
|
||||||
teams: Party[];
|
|
||||||
raidGroups: any[];
|
|
||||||
totalCount: number;
|
|
||||||
};
|
|
||||||
initialElement?: number;
|
|
||||||
initialRaid?: string;
|
|
||||||
initialRecency?: string;
|
|
||||||
error?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SavedPageClient: React.FC<Props> = ({
|
|
||||||
initialData,
|
|
||||||
initialElement,
|
|
||||||
initialRaid,
|
|
||||||
initialRecency,
|
|
||||||
error = false
|
|
||||||
}) => {
|
|
||||||
const t = useTranslations('common')
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
|
|
||||||
// State management
|
|
||||||
const [parties, setParties] = useState<Party[]>(initialData.teams)
|
|
||||||
const [element, setElement] = useState(initialElement || 0)
|
|
||||||
const [raid, setRaid] = useState(initialRaid || '')
|
|
||||||
const [recency, setRecency] = useState(initialRecency ? parseInt(initialRecency, 10) : 0)
|
|
||||||
const [fetching, setFetching] = useState(false)
|
|
||||||
|
|
||||||
// Initialize app state with raid groups
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData.raidGroups.length > 0) {
|
|
||||||
appState.raidGroups = initialData.raidGroups
|
|
||||||
}
|
|
||||||
}, [initialData.raidGroups])
|
|
||||||
|
|
||||||
// Update URL when filters change
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(searchParams?.toString() ?? '')
|
|
||||||
|
|
||||||
// Update or remove parameters based on filter values
|
|
||||||
if (element) {
|
|
||||||
params.set('element', element.toString())
|
|
||||||
} else {
|
|
||||||
params.delete('element')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raid) {
|
|
||||||
params.set('raid', raid)
|
|
||||||
} else {
|
|
||||||
params.delete('raid')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recency) {
|
|
||||||
params.set('recency', recency.toString())
|
|
||||||
} else {
|
|
||||||
params.delete('recency')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update URL if filters are changed
|
|
||||||
const newQueryString = params.toString()
|
|
||||||
const currentQuery = searchParams?.toString() ?? ''
|
|
||||||
|
|
||||||
if (newQueryString !== currentQuery) {
|
|
||||||
router.push(`/saved${newQueryString ? `?${newQueryString}` : ''}`)
|
|
||||||
}
|
|
||||||
}, [element, raid, recency, router, searchParams])
|
|
||||||
|
|
||||||
// Receive filters from the filter bar
|
|
||||||
function receiveFilters(filters: FilterSet) {
|
|
||||||
if ('element' in filters) {
|
|
||||||
setElement(filters.element || 0)
|
|
||||||
}
|
|
||||||
if ('recency' in filters) {
|
|
||||||
setRecency(filters.recency || 0)
|
|
||||||
}
|
|
||||||
if ('raid' in filters) {
|
|
||||||
setRaid(filters.raid || '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle favorite toggle
|
|
||||||
async function toggleFavorite(teamId: string, favorited: boolean) {
|
|
||||||
if (fetching) return
|
|
||||||
|
|
||||||
setFetching(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const method = favorited ? 'POST' : 'DELETE'
|
|
||||||
const body = { favorite: { party_id: teamId } }
|
|
||||||
|
|
||||||
await fetch('/api/favorites', {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update local state by removing the team if unfavorited
|
|
||||||
if (!favorited) {
|
|
||||||
setParties(parties.filter(party => party.id !== teamId))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling favorite', error)
|
|
||||||
} finally {
|
|
||||||
setFetching(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation to party page
|
|
||||||
function goToParty(shortcode: string) {
|
|
||||||
router.push(`/p/${shortcode}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page component rendering methods
|
|
||||||
function renderParties() {
|
|
||||||
return parties.map((party, i) => (
|
|
||||||
<GridRep
|
|
||||||
party={party}
|
|
||||||
key={`party-${i}`}
|
|
||||||
loading={fetching}
|
|
||||||
onClick={() => goToParty(party.shortcode)}
|
|
||||||
onSave={(teamId, favorited) => toggleFavorite(teamId, favorited)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLoading(number: number) {
|
|
||||||
return (
|
|
||||||
<GridRepCollection>
|
|
||||||
{Array.from({ length: number }, (_, i) => (
|
|
||||||
<LoadingRep key={`loading-${i}`} />
|
|
||||||
))}
|
|
||||||
</GridRepCollection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<ErrorSection
|
|
||||||
status={{
|
|
||||||
code: 500,
|
|
||||||
text: 'internal_server_error'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FilterBar
|
|
||||||
defaultFilterset={defaultFilterset}
|
|
||||||
onFilter={receiveFilters}
|
|
||||||
onAdvancedFilter={receiveFilters}
|
|
||||||
persistFilters={false}
|
|
||||||
element={element}
|
|
||||||
raid={raid}
|
|
||||||
raidGroups={initialData.raidGroups}
|
|
||||||
recency={recency}
|
|
||||||
>
|
|
||||||
<h1>{t('saved.title')}</h1>
|
|
||||||
</FilterBar>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
{parties.length === 0 ? (
|
|
||||||
<div className="notFound">
|
|
||||||
<h2>{t('saved.not_found')}</h2>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<GridRepCollection>{renderParties()}</GridRepCollection>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SavedPageClient
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
import { redirect } from 'next/navigation'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { getFavorites, getRaidGroups } from '~/app/lib/data'
|
|
||||||
import SavedPageClient from './SavedPageClient'
|
|
||||||
|
|
||||||
// Force dynamic rendering because we use cookies and searchParams
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Your saved teams / granblue.team',
|
|
||||||
description: 'View and manage the teams you have saved to your account'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is logged in server-side
|
|
||||||
function isAuthenticated() {
|
|
||||||
const cookieStore = cookies()
|
|
||||||
const accountCookie = cookieStore.get('account')
|
|
||||||
|
|
||||||
if (accountCookie) {
|
|
||||||
try {
|
|
||||||
const accountData = JSON.parse(accountCookie.value)
|
|
||||||
return accountData.token ? true : false
|
|
||||||
} catch (e) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SavedPage({
|
|
||||||
searchParams
|
|
||||||
}: {
|
|
||||||
searchParams: { element?: string; raid?: string; recency?: string; page?: string }
|
|
||||||
}) {
|
|
||||||
// Redirect to teams page if not logged in
|
|
||||||
if (!isAuthenticated()) {
|
|
||||||
redirect('/teams')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract query parameters with type safety
|
|
||||||
const element = searchParams.element ? parseInt(searchParams.element, 10) : undefined;
|
|
||||||
const raid = searchParams.raid;
|
|
||||||
const recency = searchParams.recency;
|
|
||||||
|
|
||||||
// Parallel fetch data with Promise.all for better performance
|
|
||||||
const [savedTeamsData, raidGroupsData] = await Promise.all([
|
|
||||||
getFavorites(),
|
|
||||||
getRaidGroups()
|
|
||||||
])
|
|
||||||
|
|
||||||
// Filter teams by element/raid if needed
|
|
||||||
let filteredTeams = savedTeamsData.results || [];
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
filteredTeams = filteredTeams.filter((party: any) => party.element === element)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raid) {
|
|
||||||
filteredTeams = filteredTeams.filter((party: any) => party.raid?.id === raid)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare data for client component
|
|
||||||
const initialData = {
|
|
||||||
teams: filteredTeams,
|
|
||||||
raidGroups: raidGroupsData || [],
|
|
||||||
totalCount: savedTeamsData.results?.length || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="saved-page">
|
|
||||||
<SavedPageClient
|
|
||||||
initialData={initialData}
|
|
||||||
initialElement={element}
|
|
||||||
initialRaid={raid}
|
|
||||||
initialRecency={recency}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching saved teams:", error)
|
|
||||||
|
|
||||||
// Provide empty data for error case
|
|
||||||
return (
|
|
||||||
<div className="saved-page">
|
|
||||||
<SavedPageClient
|
|
||||||
initialData={{ teams: [], raidGroups: [], totalCount: 0 }}
|
|
||||||
error={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
// Force dynamic rendering to avoid useContext issues during static generation
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Server Error / granblue.team',
|
|
||||||
description: 'The server encountered an internal error and was unable to complete your request'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ServerErrorPage() {
|
|
||||||
return (
|
|
||||||
<div className="error-container">
|
|
||||||
<div className="error-content">
|
|
||||||
<h1>Internal Server Error</h1>
|
|
||||||
<p>The server encountered an internal error and was unable to complete your request.</p>
|
|
||||||
<p>Our team has been notified and is working to fix the issue.</p>
|
|
||||||
<div className="error-actions">
|
|
||||||
<Link href="/teams" className="button primary">
|
|
||||||
Browse teams
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://discord.gg/qyZ5hGdPC8"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
className="button secondary"
|
|
||||||
>
|
|
||||||
Report on Discord
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useRouter } from '~/i18n/navigation'
|
|
||||||
import { useSearchParams } from 'next/navigation'
|
|
||||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
import { useFavorites } from '~/hooks/useFavorites'
|
|
||||||
import { useTeamFilter } from '~/hooks/useTeamFilter'
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
import { appState } from '~/utils/appState'
|
|
||||||
import { defaultFilterset } from '~/utils/defaultFilters'
|
|
||||||
import { CollectionPage } from '~/utils/enums'
|
|
||||||
|
|
||||||
// Components
|
|
||||||
import FilterBar from '~/components/filters/FilterBar'
|
|
||||||
import GridRep from '~/components/reps/GridRep'
|
|
||||||
import GridRepCollection from '~/components/reps/GridRepCollection'
|
|
||||||
import LoadingRep from '~/components/reps/LoadingRep'
|
|
||||||
import ErrorSection from '~/components/ErrorSection'
|
|
||||||
|
|
||||||
// Types
|
|
||||||
interface Pagination {
|
|
||||||
current_page: number;
|
|
||||||
total_pages: number;
|
|
||||||
record_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
initialData: {
|
|
||||||
teams: Party[];
|
|
||||||
raidGroups: any[];
|
|
||||||
pagination: Pagination;
|
|
||||||
};
|
|
||||||
initialElement?: number;
|
|
||||||
initialRaid?: string;
|
|
||||||
initialRecency?: string;
|
|
||||||
error?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TeamsPageClient: React.FC<Props> = ({
|
|
||||||
initialData,
|
|
||||||
initialElement,
|
|
||||||
initialRaid,
|
|
||||||
initialRecency,
|
|
||||||
error = false
|
|
||||||
}) => {
|
|
||||||
const t = useTranslations('common')
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
|
|
||||||
// State management
|
|
||||||
const [parties, setParties] = useState<Party[]>(initialData.teams)
|
|
||||||
const [currentPage, setCurrentPage] = useState(initialData.pagination.current_page)
|
|
||||||
const [totalPages, setTotalPages] = useState(initialData.pagination.total_pages)
|
|
||||||
const [recordCount, setRecordCount] = useState(initialData.pagination.record_count)
|
|
||||||
const [loaded, setLoaded] = useState(true)
|
|
||||||
const [fetching, setFetching] = useState(false)
|
|
||||||
const [element, setElement] = useState(initialElement || 0)
|
|
||||||
const [raid, setRaid] = useState(initialRaid || '')
|
|
||||||
const [recency, setRecency] = useState(initialRecency ? parseInt(initialRecency, 10) : 0)
|
|
||||||
const [advancedFilters, setAdvancedFilters] = useState({})
|
|
||||||
|
|
||||||
const { toggleFavorite } = useFavorites(parties, setParties)
|
|
||||||
|
|
||||||
// Initialize app state with raid groups
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData.raidGroups.length > 0) {
|
|
||||||
appState.raidGroups = initialData.raidGroups
|
|
||||||
}
|
|
||||||
}, [initialData.raidGroups])
|
|
||||||
|
|
||||||
// Update URL when filters change
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(searchParams?.toString() ?? '')
|
|
||||||
|
|
||||||
// Update or remove parameters based on filter values
|
|
||||||
if (element) {
|
|
||||||
params.set('element', element.toString())
|
|
||||||
} else {
|
|
||||||
params.delete('element')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raid) {
|
|
||||||
params.set('raid', raid)
|
|
||||||
} else {
|
|
||||||
params.delete('raid')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recency) {
|
|
||||||
params.set('recency', recency.toString())
|
|
||||||
} else {
|
|
||||||
params.delete('recency')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update URL if filters are changed
|
|
||||||
const newQueryString = params.toString()
|
|
||||||
const currentQuery = searchParams?.toString() ?? ''
|
|
||||||
|
|
||||||
if (newQueryString !== currentQuery) {
|
|
||||||
router.push(`/teams${newQueryString ? `?${newQueryString}` : ''}`)
|
|
||||||
}
|
|
||||||
}, [element, raid, recency, router, searchParams])
|
|
||||||
|
|
||||||
// Load more teams when scrolling
|
|
||||||
async function loadMoreTeams() {
|
|
||||||
if (fetching || currentPage >= totalPages) return
|
|
||||||
|
|
||||||
setFetching(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Construct URL for fetching more data
|
|
||||||
const url = new URL('/api/parties', window.location.origin)
|
|
||||||
url.searchParams.set('page', (currentPage + 1).toString())
|
|
||||||
|
|
||||||
if (element) url.searchParams.set('element', element.toString())
|
|
||||||
if (raid) url.searchParams.set('raid', raid)
|
|
||||||
if (recency) url.searchParams.set('recency', recency.toString())
|
|
||||||
|
|
||||||
const response = await fetch(url.toString())
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (data.parties && Array.isArray(data.parties)) {
|
|
||||||
setParties([...parties, ...data.parties])
|
|
||||||
setCurrentPage(data.pagination?.current_page || currentPage + 1)
|
|
||||||
setTotalPages(data.pagination?.total_pages || totalPages)
|
|
||||||
setRecordCount(data.pagination?.record_count || recordCount)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading more teams', error)
|
|
||||||
} finally {
|
|
||||||
setFetching(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receive filters from the filter bar
|
|
||||||
function receiveFilters(filters: FilterSet) {
|
|
||||||
if ('element' in filters) {
|
|
||||||
setElement(filters.element || 0)
|
|
||||||
}
|
|
||||||
if ('recency' in filters) {
|
|
||||||
setRecency(filters.recency || 0)
|
|
||||||
}
|
|
||||||
if ('raid' in filters) {
|
|
||||||
setRaid(filters.raid || '')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
|
||||||
setCurrentPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function receiveAdvancedFilters(filters: FilterSet) {
|
|
||||||
setAdvancedFilters(filters)
|
|
||||||
// Reset to page 1 when filters change
|
|
||||||
setCurrentPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Navigation
|
|
||||||
function goTo(shortcode: string) {
|
|
||||||
router.push(`/p/${shortcode}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page component rendering methods
|
|
||||||
function renderParties() {
|
|
||||||
return parties.map((party, i) => (
|
|
||||||
<GridRep
|
|
||||||
party={party}
|
|
||||||
key={`party-${i}`}
|
|
||||||
loading={fetching}
|
|
||||||
onClick={() => goTo(party.shortcode)}
|
|
||||||
onSave={(teamId, favorited) => toggleFavorite(teamId, favorited)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLoading(number: number) {
|
|
||||||
return (
|
|
||||||
<GridRepCollection>
|
|
||||||
{Array.from({ length: number }, (_, i) => (
|
|
||||||
<LoadingRep key={`loading-${i}`} />
|
|
||||||
))}
|
|
||||||
</GridRepCollection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<ErrorSection
|
|
||||||
status={{
|
|
||||||
code: 500,
|
|
||||||
text: 'internal_server_error'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderInfiniteScroll = (
|
|
||||||
<>
|
|
||||||
{parties.length === 0 && !loaded && renderLoading(3)}
|
|
||||||
{parties.length === 0 && loaded && (
|
|
||||||
<div className="notFound">
|
|
||||||
<h2>{t('teams.not_found')}</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{parties.length > 0 && (
|
|
||||||
<InfiniteScroll
|
|
||||||
dataLength={parties.length}
|
|
||||||
next={loadMoreTeams}
|
|
||||||
hasMore={totalPages > currentPage}
|
|
||||||
loader={renderLoading(3)}
|
|
||||||
>
|
|
||||||
<GridRepCollection>{renderParties()}</GridRepCollection>
|
|
||||||
</InfiniteScroll>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FilterBar
|
|
||||||
defaultFilterset={defaultFilterset}
|
|
||||||
onFilter={receiveFilters}
|
|
||||||
onAdvancedFilter={receiveAdvancedFilters}
|
|
||||||
persistFilters={true}
|
|
||||||
element={element}
|
|
||||||
raid={raid}
|
|
||||||
raidGroups={initialData.raidGroups}
|
|
||||||
recency={recency}
|
|
||||||
>
|
|
||||||
<h1>{t('teams.title')}</h1>
|
|
||||||
</FilterBar>
|
|
||||||
|
|
||||||
<section>{renderInfiniteScroll}</section>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TeamsPageClient
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
import React from 'react'
|
|
||||||
import { getTeams as fetchTeams, getRaidGroups } from '~/app/lib/data'
|
|
||||||
import TeamsPageClient from './TeamsPageClient'
|
|
||||||
|
|
||||||
// Force dynamic rendering because we use searchParams
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Discover teams / granblue.team',
|
|
||||||
description: 'Save and discover teams to use in Granblue Fantasy and search by raid, element or recency',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function TeamsPage({
|
|
||||||
searchParams
|
|
||||||
}: {
|
|
||||||
searchParams: { element?: string; raid?: string; recency?: string; page?: string }
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
// Extract query parameters with type safety
|
|
||||||
const element = searchParams.element ? parseInt(searchParams.element, 10) : undefined;
|
|
||||||
const raid = searchParams.raid;
|
|
||||||
const recency = searchParams.recency;
|
|
||||||
const page = searchParams.page ? parseInt(searchParams.page, 10) : 1;
|
|
||||||
|
|
||||||
// Parallel fetch data with Promise.all for better performance
|
|
||||||
const [teamsData, raidGroupsData] = await Promise.all([
|
|
||||||
fetchTeams({ element, raid, recency, page }),
|
|
||||||
getRaidGroups()
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Prepare data for client component
|
|
||||||
const initialData = {
|
|
||||||
teams: teamsData.results || [],
|
|
||||||
raidGroups: raidGroupsData || [],
|
|
||||||
pagination: {
|
|
||||||
current_page: page,
|
|
||||||
total_pages: teamsData.meta?.total_pages || 1,
|
|
||||||
record_count: teamsData.meta?.count || 0
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="teams">
|
|
||||||
{/* Pass server data to client component */}
|
|
||||||
<TeamsPageClient
|
|
||||||
initialData={initialData}
|
|
||||||
initialElement={element}
|
|
||||||
initialRaid={raid}
|
|
||||||
initialRecency={recency}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching teams data:", error);
|
|
||||||
|
|
||||||
// Fallback data for error case
|
|
||||||
return (
|
|
||||||
<div className="teams">
|
|
||||||
<TeamsPageClient
|
|
||||||
initialData={{ teams: [], raidGroups: [], pagination: { current_page: 1, total_pages: 1, record_count: 0 } }}
|
|
||||||
error={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
// Force dynamic rendering to avoid useContext issues during static generation
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Unauthorized / granblue.team',
|
|
||||||
description: "You don't have permission to perform that action"
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UnauthorizedPage() {
|
|
||||||
return (
|
|
||||||
<div className="error-container">
|
|
||||||
<div className="error-content">
|
|
||||||
<h1>Unauthorized</h1>
|
|
||||||
<p>You don't have permission to perform that action</p>
|
|
||||||
<div className="error-actions">
|
|
||||||
<Link href="/teams" className="button primary">
|
|
||||||
Browse teams
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useRouter } from '~/i18n/navigation'
|
|
||||||
import { AboutTabs } from '~/utils/enums'
|
|
||||||
|
|
||||||
import AboutPage from '~/components/about/AboutPage'
|
|
||||||
import UpdatesPage from '~/components/about/UpdatesPage'
|
|
||||||
import RoadmapPage from '~/components/about/RoadmapPage'
|
|
||||||
import SegmentedControl from '~/components/common/SegmentedControl'
|
|
||||||
import Segment from '~/components/common/Segment'
|
|
||||||
|
|
||||||
export default function UpdatesPageClient() {
|
|
||||||
const t = useTranslations('common')
|
|
||||||
const router = useRouter()
|
|
||||||
const [currentTab] = useState<AboutTabs>(AboutTabs.Updates)
|
|
||||||
|
|
||||||
function handleTabClicked(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const value = event.target.value
|
|
||||||
router.push(`/${value}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSection = () => {
|
|
||||||
switch (currentTab) {
|
|
||||||
case AboutTabs.About:
|
|
||||||
return <AboutPage />
|
|
||||||
case AboutTabs.Updates:
|
|
||||||
return <UpdatesPage />
|
|
||||||
case AboutTabs.Roadmap:
|
|
||||||
return <RoadmapPage />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<SegmentedControl blended={true}>
|
|
||||||
<Segment
|
|
||||||
groupName="about"
|
|
||||||
name="about"
|
|
||||||
selected={currentTab == AboutTabs.About}
|
|
||||||
onClick={handleTabClicked}
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.about')}
|
|
||||||
</Segment>
|
|
||||||
<Segment
|
|
||||||
groupName="about"
|
|
||||||
name="updates"
|
|
||||||
selected={currentTab == AboutTabs.Updates}
|
|
||||||
onClick={handleTabClicked}
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.updates')}
|
|
||||||
</Segment>
|
|
||||||
<Segment
|
|
||||||
groupName="about"
|
|
||||||
name="roadmap"
|
|
||||||
selected={currentTab == AboutTabs.Roadmap}
|
|
||||||
onClick={handleTabClicked}
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.roadmap')}
|
|
||||||
</Segment>
|
|
||||||
</SegmentedControl>
|
|
||||||
{currentSection()}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
import { getTranslations } from 'next-intl/server'
|
|
||||||
|
|
||||||
// Force dynamic rendering to avoid useContext issues during static generation
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
import UpdatesPageClient from './UpdatesPageClient'
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
|
||||||
params: { locale }
|
|
||||||
}: {
|
|
||||||
params: { locale: string }
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const t = await getTranslations({ locale, namespace: 'common' })
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: t('page.titles.updates'),
|
|
||||||
description: t('page.descriptions.updates')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function UpdatesPage({
|
|
||||||
params: { locale }
|
|
||||||
}: {
|
|
||||||
params: { locale: string }
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div id="About">
|
|
||||||
<UpdatesPageClient />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { login as loginHelper } from '~/app/lib/api-utils'
|
|
||||||
|
|
||||||
// Login request schema
|
|
||||||
const LoginSchema = z.object({
|
|
||||||
email: z.string().email('Invalid email format'),
|
|
||||||
password: z.string().min(8, 'Password must be at least 8 characters')
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Parse and validate request body
|
|
||||||
const body = await request.json()
|
|
||||||
const validatedData = LoginSchema.parse(body)
|
|
||||||
|
|
||||||
// Call login helper with credentials
|
|
||||||
const response = await loginHelper(validatedData)
|
|
||||||
|
|
||||||
// Set cookies based on response
|
|
||||||
if (response.token) {
|
|
||||||
// Calculate expiration (60 days)
|
|
||||||
const expiresAt = new Date()
|
|
||||||
expiresAt.setDate(expiresAt.getDate() + 60)
|
|
||||||
|
|
||||||
// Set account cookie with auth info
|
|
||||||
const accountCookie = {
|
|
||||||
userId: response.user_id,
|
|
||||||
username: response.username,
|
|
||||||
role: response.role,
|
|
||||||
token: response.token
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set user cookie with preferences/profile
|
|
||||||
const userCookie = {
|
|
||||||
avatar: {
|
|
||||||
picture: response.avatar.picture,
|
|
||||||
element: response.avatar.element
|
|
||||||
},
|
|
||||||
gender: response.gender,
|
|
||||||
language: response.language,
|
|
||||||
theme: response.theme,
|
|
||||||
bahamut: response.bahamut || false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set cookies
|
|
||||||
const cookieStore = cookies()
|
|
||||||
cookieStore.set('account', JSON.stringify(accountCookie), {
|
|
||||||
expires: expiresAt,
|
|
||||||
path: '/',
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'strict'
|
|
||||||
})
|
|
||||||
|
|
||||||
cookieStore.set('user', JSON.stringify(userCookie), {
|
|
||||||
expires: expiresAt,
|
|
||||||
path: '/',
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'strict'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return success
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
user: {
|
|
||||||
username: response.username,
|
|
||||||
avatar: response.avatar
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we get here, something went wrong
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid login response' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Validation error', details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For authentication errors
|
|
||||||
if (error && typeof error === 'object' && 'response' in error) {
|
|
||||||
const axiosError = error as any
|
|
||||||
if (axiosError.response?.status === 401) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid email or password' },
|
|
||||||
{ status: 401 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Login error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Login failed' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Delete cookies
|
|
||||||
const cookieStore = cookies()
|
|
||||||
cookieStore.delete('account')
|
|
||||||
cookieStore.delete('user')
|
|
||||||
|
|
||||||
// Return success
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Logout failed' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { postToApi } from '~/app/lib/api-utils'
|
|
||||||
|
|
||||||
// Signup request schema
|
|
||||||
const SignupSchema = z.object({
|
|
||||||
username: z.string()
|
|
||||||
.min(3, 'Username must be at least 3 characters')
|
|
||||||
.max(20, 'Username must be less than 20 characters')
|
|
||||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and hyphens'),
|
|
||||||
email: z.string().email('Invalid email format'),
|
|
||||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
||||||
password_confirmation: z.string()
|
|
||||||
}).refine(data => data.password === data.password_confirmation, {
|
|
||||||
message: "Passwords don't match",
|
|
||||||
path: ['password_confirmation']
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Parse and validate request body
|
|
||||||
const body = await request.json()
|
|
||||||
const validatedData = SignupSchema.parse(body)
|
|
||||||
|
|
||||||
// Call signup endpoint
|
|
||||||
const response = await postToApi('/users', {
|
|
||||||
user: {
|
|
||||||
username: validatedData.username,
|
|
||||||
email: validatedData.email,
|
|
||||||
password: validatedData.password,
|
|
||||||
password_confirmation: validatedData.password_confirmation
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return created user info
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
user: {
|
|
||||||
username: response.username,
|
|
||||||
email: response.email
|
|
||||||
}
|
|
||||||
}, { status: 201 })
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Validation error', details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle specific API errors
|
|
||||||
if (error && typeof error === 'object' && 'response' in error) {
|
|
||||||
const axiosError = error as any
|
|
||||||
if (axiosError.response?.data?.error) {
|
|
||||||
const apiError = axiosError.response.data.error
|
|
||||||
|
|
||||||
// Username or email already in use
|
|
||||||
if (apiError.includes('username') || apiError.includes('email')) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: apiError },
|
|
||||||
{ status: 409 } // Conflict
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Signup error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Signup failed' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { fetchFromApi } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// GET handler for fetching a single character
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Character ID is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchFromApi(`/characters/${id}`);
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error fetching character ${params.id}`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch character' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { fetchFromApi, postToApi, deleteFromApi } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// Schema for favorite request
|
|
||||||
const FavoriteSchema = z.object({
|
|
||||||
favorite: z.object({
|
|
||||||
party_id: z.string()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET handler for fetching user's favorites
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Get saved teams/favorites
|
|
||||||
const data = await fetchFromApi('/parties/favorites');
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching favorites', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch favorites' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST handler for adding a favorite
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Validate request
|
|
||||||
const validatedData = FavoriteSchema.parse(body);
|
|
||||||
|
|
||||||
// Save the favorite
|
|
||||||
const response = await postToApi('/favorites', validatedData);
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Validation error', details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Error saving favorite', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to save favorite' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE handler for removing a favorite
|
|
||||||
export async function DELETE(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Validate request
|
|
||||||
const validatedData = FavoriteSchema.parse(body);
|
|
||||||
|
|
||||||
// Delete the favorite
|
|
||||||
const response = await deleteFromApi('/favorites', validatedData);
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Validation error', details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Error removing favorite', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to remove favorite' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { fetchFromApi } from '~/app/lib/api-utils'
|
|
||||||
|
|
||||||
// GET handler for fetching accessories for a specific job
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = params
|
|
||||||
|
|
||||||
const data = await fetchFromApi(`/jobs/${id}/accessories`)
|
|
||||||
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error fetching accessories for job ${params.id}`, error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch job accessories' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { fetchFromApi } from '~/app/lib/api-utils'
|
|
||||||
|
|
||||||
// GET handler for fetching skills for a specific job
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = params
|
|
||||||
|
|
||||||
const data = await fetchFromApi(`/jobs/${id}/skills`)
|
|
||||||
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error fetching skills for job ${params.id}`, error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch job skills' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { fetchFromApi } from '~/app/lib/api-utils'
|
|
||||||
|
|
||||||
// Force dynamic rendering because we use searchParams
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
// GET handler for fetching all jobs
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Parse URL parameters
|
|
||||||
const searchParams = request.nextUrl.searchParams
|
|
||||||
const element = searchParams.get('element')
|
|
||||||
|
|
||||||
// Build query parameters
|
|
||||||
const queryParams: Record<string, string> = {}
|
|
||||||
if (element) queryParams.element = element
|
|
||||||
|
|
||||||
// Append query parameters
|
|
||||||
let endpoint = '/jobs'
|
|
||||||
const queryString = new URLSearchParams(queryParams).toString()
|
|
||||||
if (queryString) {
|
|
||||||
endpoint += `?${queryString}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchFromApi(endpoint)
|
|
||||||
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching jobs', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch jobs' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { fetchFromApi } from '~/app/lib/api-utils'
|
|
||||||
|
|
||||||
// Force dynamic rendering because fetchFromApi uses cookies
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
// GET handler for fetching all job skills
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const data = await fetchFromApi('/jobs/skills')
|
|
||||||
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching job skills', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch job skills' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { postToApi, revalidate } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// Force dynamic rendering because postToApi uses cookies
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
// POST handler for remixing a party
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { shortcode: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { shortcode } = params;
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Remix the party
|
|
||||||
const response = await postToApi(`/parties/${shortcode}/remix`, body || {});
|
|
||||||
|
|
||||||
// Revalidate the teams page since a new party was created
|
|
||||||
revalidate('/teams');
|
|
||||||
|
|
||||||
if (response.shortcode) {
|
|
||||||
// Revalidate the new party page
|
|
||||||
revalidate(`/p/${response.shortcode}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error remixing party with shortcode ${params.shortcode}`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to remix party' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { fetchFromApi, putToApi, deleteFromApi, revalidate, PartySchema } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// Force dynamic rendering because fetchFromApi uses cookies
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
// GET handler for fetching a single party by shortcode
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { shortcode: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { shortcode } = params;
|
|
||||||
|
|
||||||
// Fetch party data
|
|
||||||
const data = await fetchFromApi(`/parties/${shortcode}`);
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error fetching party with shortcode ${params.shortcode}`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch party' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update party schema
|
|
||||||
const UpdatePartySchema = PartySchema.extend({
|
|
||||||
id: z.string().optional(),
|
|
||||||
shortcode: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// PUT handler for updating a party
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { shortcode: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { shortcode } = params;
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Validate the request body
|
|
||||||
const validatedData = UpdatePartySchema.parse(body.party);
|
|
||||||
|
|
||||||
// Update the party
|
|
||||||
const response = await putToApi(`/parties/${shortcode}`, {
|
|
||||||
party: validatedData
|
|
||||||
});
|
|
||||||
|
|
||||||
// Revalidate the party page
|
|
||||||
revalidate(`/p/${shortcode}`);
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Validation error', details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to update party' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE handler for deleting a party
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { shortcode: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { shortcode } = params;
|
|
||||||
|
|
||||||
// Delete the party
|
|
||||||
const response = await deleteFromApi(`/parties/${shortcode}`);
|
|
||||||
|
|
||||||
// Revalidate related pages
|
|
||||||
revalidate(`/teams`);
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to delete party' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { fetchFromApi, postToApi, PartySchema } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// Force dynamic rendering because we use searchParams and cookies
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
// GET handler for fetching parties with filters
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Parse URL parameters
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
|
||||||
const element = searchParams.get('element');
|
|
||||||
const raid = searchParams.get('raid');
|
|
||||||
const recency = searchParams.get('recency');
|
|
||||||
const page = searchParams.get('page') || '1';
|
|
||||||
const username = searchParams.get('username');
|
|
||||||
|
|
||||||
// Build query parameters
|
|
||||||
const queryParams: Record<string, string> = {};
|
|
||||||
if (element) queryParams.element = element;
|
|
||||||
if (raid) queryParams.raid_id = raid;
|
|
||||||
if (recency) queryParams.recency = recency;
|
|
||||||
if (page) queryParams.page = page;
|
|
||||||
|
|
||||||
let endpoint = '/parties';
|
|
||||||
|
|
||||||
// If username is provided, fetch that user's parties
|
|
||||||
if (username) {
|
|
||||||
endpoint = `/users/${username}/parties`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append query parameters
|
|
||||||
const queryString = new URLSearchParams(queryParams).toString();
|
|
||||||
if (queryString) {
|
|
||||||
endpoint += `?${queryString}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchFromApi(endpoint);
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching parties', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch parties' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate party data
|
|
||||||
const CreatePartySchema = PartySchema.extend({
|
|
||||||
element: z.number().min(1).max(6),
|
|
||||||
raid_id: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST handler for creating a new party
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Validate the request body
|
|
||||||
const validatedData = CreatePartySchema.parse(body.party);
|
|
||||||
|
|
||||||
const response = await postToApi('/parties', {
|
|
||||||
party: validatedData
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Validation error', details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to create party' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { fetchFromApi } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// GET handler for fetching a single raid
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Raid ID is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchFromApi(`/raids/${id}`);
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error fetching raid ${params.id}`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch raid' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { fetchFromApi } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// Force dynamic rendering because fetchFromApi uses cookies
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
// GET handler for fetching raid groups
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Fetch raid groups
|
|
||||||
const data = await fetchFromApi('/raids/groups');
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching raid groups', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch raid groups' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { postToApi, SearchSchema } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// Validate the object type
|
|
||||||
const ObjectTypeSchema = z.enum(['characters', 'weapons', 'summons', 'job_skills']);
|
|
||||||
|
|
||||||
// POST handler for search
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { object: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { object } = params;
|
|
||||||
|
|
||||||
// Validate object type
|
|
||||||
const validObjectType = ObjectTypeSchema.safeParse(object);
|
|
||||||
if (!validObjectType.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Invalid object type: ${object}` },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Validate search parameters
|
|
||||||
const validatedSearch = SearchSchema.parse(body.search);
|
|
||||||
|
|
||||||
// Perform search
|
|
||||||
const response = await postToApi(`/search/${object}`, {
|
|
||||||
search: validatedSearch
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Validation error', details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(`Error searching ${params.object}`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Search failed' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { postToApi } from '~/app/lib/api-utils'
|
|
||||||
|
|
||||||
// Validate the search request
|
|
||||||
const SearchAllSchema = z.object({
|
|
||||||
search: z.object({
|
|
||||||
query: z.string().min(1, 'Search query is required'),
|
|
||||||
exclude: z.array(z.string()).optional(),
|
|
||||||
locale: z.string().default('en')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST handler for searching across all types
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Parse and validate request body
|
|
||||||
const body = await request.json()
|
|
||||||
const validatedData = SearchAllSchema.parse(body)
|
|
||||||
|
|
||||||
// Perform search
|
|
||||||
const response = await postToApi('/search', validatedData)
|
|
||||||
|
|
||||||
return NextResponse.json(response)
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Validation error', details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Error searching', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Search failed' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { fetchFromApi } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// GET handler for fetching a single summon
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Summon ID is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchFromApi(`/summons/${id}`);
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error fetching summon ${params.id}`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch summon' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { fetchFromApi } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// GET handler for fetching user info
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { username: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { username } = params;
|
|
||||||
|
|
||||||
// Fetch user info
|
|
||||||
const data = await fetchFromApi(`/users/info/${username}`);
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error fetching user info for ${params.username}`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch user info' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { putToApi } from '~/app/lib/api-utils'
|
|
||||||
|
|
||||||
// Settings update schema
|
|
||||||
const SettingsSchema = z.object({
|
|
||||||
picture: z.string().optional(),
|
|
||||||
gender: z.enum(['gran', 'djeeta']).optional(),
|
|
||||||
language: z.enum(['en', 'ja']).optional(),
|
|
||||||
theme: z.enum(['light', 'dark', 'system']).optional(),
|
|
||||||
bahamut: z.boolean().optional()
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function PUT(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Parse and validate request body
|
|
||||||
const body = await request.json()
|
|
||||||
const validatedData = SettingsSchema.parse(body)
|
|
||||||
|
|
||||||
// Get user info from cookie
|
|
||||||
const cookieStore = cookies()
|
|
||||||
const accountCookie = cookieStore.get('account')
|
|
||||||
|
|
||||||
if (!accountCookie) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Authentication required' },
|
|
||||||
{ status: 401 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse account cookie
|
|
||||||
const accountData = JSON.parse(accountCookie.value)
|
|
||||||
|
|
||||||
// Call API to update settings
|
|
||||||
const response = await putToApi(`/users/${accountData.userId}`, {
|
|
||||||
user: validatedData
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update user cookie with new settings
|
|
||||||
const userCookie = cookieStore.get('user')
|
|
||||||
if (userCookie) {
|
|
||||||
const userData = JSON.parse(userCookie.value)
|
|
||||||
|
|
||||||
// Update user data
|
|
||||||
const updatedUserData = {
|
|
||||||
...userData,
|
|
||||||
avatar: {
|
|
||||||
...userData.avatar,
|
|
||||||
picture: validatedData.picture || userData.avatar.picture
|
|
||||||
},
|
|
||||||
gender: validatedData.gender || userData.gender,
|
|
||||||
language: validatedData.language || userData.language,
|
|
||||||
theme: validatedData.theme || userData.theme,
|
|
||||||
bahamut: validatedData.bahamut !== undefined ? validatedData.bahamut : userData.bahamut
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set updated cookie
|
|
||||||
const expiresAt = new Date()
|
|
||||||
expiresAt.setDate(expiresAt.getDate() + 60)
|
|
||||||
|
|
||||||
cookieStore.set('user', JSON.stringify(updatedUserData), {
|
|
||||||
expires: expiresAt,
|
|
||||||
path: '/',
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'strict'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return updated user info
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
user: response
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Validation error', details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Settings update error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to update settings' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { fetchFromApi } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// Force dynamic rendering because fetchFromApi uses cookies
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
// GET handler for fetching version info
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Fetch version info
|
|
||||||
const data = await fetchFromApi('/version');
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching version info', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch version info' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { fetchFromApi } from '~/app/lib/api-utils';
|
|
||||||
|
|
||||||
// GET handler for fetching a single weapon
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Weapon ID is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchFromApi(`/weapons/${id}`);
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error fetching weapon ${params.id}`, error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: error.message || 'Failed to fetch weapon' },
|
|
||||||
{ status: error.response?.status || 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,404 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { deleteCookie } from 'cookies-next'
|
|
||||||
import { useRouter } from '~/i18n/navigation'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useLocale } from 'next-intl'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
import clonedeep from 'lodash.clonedeep'
|
|
||||||
import { Link } from '~/i18n/navigation'
|
|
||||||
|
|
||||||
import { accountState, initialAccountState } from '~/utils/accountState'
|
|
||||||
import { appState, initialAppState } from '~/utils/appState'
|
|
||||||
|
|
||||||
import Alert from '~/components/common/Alert'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from '~/components/common/DropdownMenuContent'
|
|
||||||
import DropdownMenuGroup from '~/components/common/DropdownMenuGroup'
|
|
||||||
import DropdownMenuLabel from '~/components/common/DropdownMenuLabel'
|
|
||||||
import DropdownMenuItem from '~/components/common/DropdownMenuItem'
|
|
||||||
import LanguageSwitch from '~/components/LanguageSwitch'
|
|
||||||
import LoginModal from '~/components/auth/LoginModal'
|
|
||||||
import SignupModal from '~/components/auth/SignupModal'
|
|
||||||
import AccountModal from '~/components/auth/AccountModal'
|
|
||||||
import Button from '~/components/common/Button'
|
|
||||||
import Tooltip from '~/components/common/Tooltip'
|
|
||||||
|
|
||||||
import BahamutIcon from '~/public/icons/Bahamut.svg'
|
|
||||||
import ChevronIcon from '~/public/icons/Chevron.svg'
|
|
||||||
import MenuIcon from '~/public/icons/Menu.svg'
|
|
||||||
import PlusIcon from '~/public/icons/Add.svg'
|
|
||||||
|
|
||||||
import styles from '~/components/Header/index.module.scss'
|
|
||||||
|
|
||||||
const Header = () => {
|
|
||||||
// Localization
|
|
||||||
const t = useTranslations('common')
|
|
||||||
const locale = useLocale()
|
|
||||||
|
|
||||||
// Router
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// State management
|
|
||||||
const [alertOpen, setAlertOpen] = useState(false)
|
|
||||||
const [loginModalOpen, setLoginModalOpen] = useState(false)
|
|
||||||
const [signupModalOpen, setSignupModalOpen] = useState(false)
|
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
|
|
||||||
const [leftMenuOpen, setLeftMenuOpen] = useState(false)
|
|
||||||
const [rightMenuOpen, setRightMenuOpen] = useState(false)
|
|
||||||
|
|
||||||
// Methods: Event handlers (Buttons)
|
|
||||||
function handleLeftMenuButtonClicked() {
|
|
||||||
setLeftMenuOpen(!leftMenuOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRightMenuButtonClicked() {
|
|
||||||
setRightMenuOpen(!rightMenuOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Event handlers (Menus)
|
|
||||||
function handleLeftMenuOpenChange(open: boolean) {
|
|
||||||
setLeftMenuOpen(open)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRightMenuOpenChange(open: boolean) {
|
|
||||||
setRightMenuOpen(open)
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLeftMenu() {
|
|
||||||
setLeftMenuOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeRightMenu() {
|
|
||||||
setRightMenuOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Actions
|
|
||||||
function handleNewTeam(event: React.MouseEvent) {
|
|
||||||
event.preventDefault()
|
|
||||||
newTeam()
|
|
||||||
closeRightMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
// Close menu
|
|
||||||
closeRightMenu()
|
|
||||||
|
|
||||||
// Delete cookies
|
|
||||||
deleteCookie('account')
|
|
||||||
deleteCookie('user')
|
|
||||||
|
|
||||||
// Clean state
|
|
||||||
const resetState = clonedeep(initialAccountState)
|
|
||||||
Object.keys(resetState).forEach((key) => {
|
|
||||||
if (key !== 'language') accountState[key] = resetState[key]
|
|
||||||
})
|
|
||||||
|
|
||||||
router.refresh()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function newTeam() {
|
|
||||||
// Clean state
|
|
||||||
const resetState = clonedeep(initialAppState)
|
|
||||||
Object.keys(resetState).forEach((key) => {
|
|
||||||
appState[key] = resetState[key]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Push the new URL
|
|
||||||
router.push('/new')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Rendering
|
|
||||||
const profileImage = () => {
|
|
||||||
const user = accountState.account.user
|
|
||||||
if (accountState.account.authorized && user) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
alt={user.username}
|
|
||||||
className={`profile ${user.avatar.element}`}
|
|
||||||
srcSet={`/profile/${user.avatar.picture}.png,
|
|
||||||
/profile/${user.avatar.picture}@2x.png 2x`}
|
|
||||||
src={`/profile/${user.avatar.picture}.png`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
alt={t('no_user')}
|
|
||||||
className={`profile anonymous`}
|
|
||||||
srcSet={`/profile/npc.png,
|
|
||||||
/profile/npc@2x.png 2x`}
|
|
||||||
src={`/profile/npc.png`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendering: Buttons
|
|
||||||
const newButton = (
|
|
||||||
<Tooltip content={t('tooltips.new')}>
|
|
||||||
<Button
|
|
||||||
leftAccessoryIcon={<PlusIcon />}
|
|
||||||
className="New"
|
|
||||||
blended={true}
|
|
||||||
text={t('buttons.new')}
|
|
||||||
onClick={newTeam}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rendering: Modals
|
|
||||||
const logoutConfirmationAlert = (
|
|
||||||
<Alert
|
|
||||||
message={t('alert.confirm_logout')}
|
|
||||||
open={alertOpen}
|
|
||||||
primaryActionText="Log out"
|
|
||||||
primaryAction={logout}
|
|
||||||
cancelActionText="Nevermind"
|
|
||||||
cancelAction={() => setAlertOpen(false)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const settingsModal = (
|
|
||||||
<>
|
|
||||||
{accountState.account.user && (
|
|
||||||
<AccountModal
|
|
||||||
open={settingsModalOpen}
|
|
||||||
username={accountState.account.user.username}
|
|
||||||
picture={accountState.account.user.avatar.picture}
|
|
||||||
gender={accountState.account.user.gender}
|
|
||||||
language={accountState.account.user.language}
|
|
||||||
theme={accountState.account.user.theme}
|
|
||||||
role={accountState.account.user.role}
|
|
||||||
bahamutMode={
|
|
||||||
accountState.account.user.role === 9
|
|
||||||
? accountState.account.user.bahamut
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onOpenChange={setSettingsModalOpen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const loginModal = (
|
|
||||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
|
||||||
)
|
|
||||||
|
|
||||||
const signupModal = (
|
|
||||||
<SignupModal open={signupModalOpen} onOpenChange={setSignupModalOpen} />
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rendering: Compositing
|
|
||||||
const authorizedLeftItems = (
|
|
||||||
<>
|
|
||||||
{accountState.account.user && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<Link
|
|
||||||
href={`/${accountState.account.user.username}` || ''}
|
|
||||||
>
|
|
||||||
<span>{t('menu.profile')}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<Link href={`/saved` || ''}>{t('menu.saved')}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
const leftMenuItems = (
|
|
||||||
<>
|
|
||||||
{accountState.account.authorized &&
|
|
||||||
accountState.account.user &&
|
|
||||||
authorizedLeftItems}
|
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<Link href="/teams">{t('menu.teams')}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<div>
|
|
||||||
<span>{t('menu.guides')}</span>
|
|
||||||
<i className="tag">{t('coming_soon')}</i>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<a
|
|
||||||
href={locale == 'ja' ? '/ja/about' : '/about'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.about')}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<a
|
|
||||||
href={locale == 'ja' ? '/ja/updates' : '/updates'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.updates')}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<a
|
|
||||||
href={locale == 'ja' ? '/ja/roadmap' : '/roadmap'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.roadmap')}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const left = (
|
|
||||||
<section>
|
|
||||||
<div className={styles.dropdownWrapper}>
|
|
||||||
<DropdownMenu
|
|
||||||
open={leftMenuOpen}
|
|
||||||
onOpenChange={handleLeftMenuOpenChange}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
active={leftMenuOpen}
|
|
||||||
blended={true}
|
|
||||||
leftAccessoryIcon={<MenuIcon />}
|
|
||||||
onClick={handleLeftMenuButtonClicked}
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="Left">
|
|
||||||
{leftMenuItems}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
|
|
||||||
const authorizedRightItems = (
|
|
||||||
<>
|
|
||||||
{accountState.account.user && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
{`@${accountState.account.user.username}`}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem onClick={closeRightMenu}>
|
|
||||||
<Link
|
|
||||||
href={`/${accountState.account.user.username}` || ''}
|
|
||||||
>
|
|
||||||
<span>{t('menu.profile')}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="MenuItem"
|
|
||||||
onClick={() => setSettingsModalOpen(true)}
|
|
||||||
>
|
|
||||||
<span>{t('menu.settings')}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setAlertOpen(true)}
|
|
||||||
destructive={true}
|
|
||||||
>
|
|
||||||
<span>{t('menu.logout')}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const unauthorizedRightItems = (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem className="language">
|
|
||||||
<span>{t('menu.language')}</span>
|
|
||||||
<LanguageSwitch />
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuGroup className="MenuGroup">
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="MenuItem"
|
|
||||||
onClick={() => setLoginModalOpen(true)}
|
|
||||||
>
|
|
||||||
<span>{t('menu.login')}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="MenuItem"
|
|
||||||
onClick={() => setSignupModalOpen(true)}
|
|
||||||
>
|
|
||||||
<span>{t('menu.signup')}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const rightMenuItems = (
|
|
||||||
<>
|
|
||||||
{accountState.account.authorized && accountState.account.user
|
|
||||||
? authorizedRightItems
|
|
||||||
: unauthorizedRightItems}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const right = (
|
|
||||||
<section>
|
|
||||||
{newButton}
|
|
||||||
<DropdownMenu
|
|
||||||
open={rightMenuOpen}
|
|
||||||
onOpenChange={handleRightMenuOpenChange}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className={classNames({ Active: rightMenuOpen })}
|
|
||||||
leftAccessoryIcon={profileImage()}
|
|
||||||
rightAccessoryIcon={<ChevronIcon />}
|
|
||||||
rightAccessoryClassName="Arrow"
|
|
||||||
onClick={handleRightMenuButtonClicked}
|
|
||||||
blended={true}
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="Right">
|
|
||||||
{rightMenuItems}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{accountState.account.user?.bahamut && (
|
|
||||||
<div className={styles.bahamut}>
|
|
||||||
<BahamutIcon />
|
|
||||||
<p>Bahamut Mode is active</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<nav className={styles.header}>
|
|
||||||
{left}
|
|
||||||
{right}
|
|
||||||
{logoutConfirmationAlert}
|
|
||||||
{settingsModal}
|
|
||||||
{loginModal}
|
|
||||||
{signupModal}
|
|
||||||
</nav>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { ReactNode } from 'react'
|
|
||||||
import { ThemeProvider } from 'next-themes'
|
|
||||||
import { ToastProvider } from '@radix-ui/react-toast'
|
|
||||||
import { TooltipProvider } from '@radix-ui/react-tooltip'
|
|
||||||
|
|
||||||
export default function Providers({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<ThemeProvider>
|
|
||||||
<ToastProvider swipeDirection="right">
|
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
|
||||||
</ToastProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import { add, format } from 'date-fns'
|
|
||||||
import { getCookie } from 'cookies-next'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
|
|
||||||
import { appState } from '~/utils/appState'
|
|
||||||
import UpdateToast from '~/components/toasts/UpdateToast'
|
|
||||||
|
|
||||||
interface UpdateToastClientProps {
|
|
||||||
initialVersion?: AppUpdate | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UpdateToastClient({ initialVersion }: UpdateToastClientProps) {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const [updateToastOpen, setUpdateToastOpen] = useState(false)
|
|
||||||
const { version } = useSnapshot(appState)
|
|
||||||
|
|
||||||
// Use initialVersion for SSR, then switch to appState version after hydration
|
|
||||||
const effectiveVersion = version?.updated_at ? version : initialVersion
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (effectiveVersion && effectiveVersion.updated_at) {
|
|
||||||
const cookie = getToastCookie()
|
|
||||||
const now = new Date()
|
|
||||||
const updatedAt = new Date(effectiveVersion.updated_at)
|
|
||||||
const validUntil = add(updatedAt, { days: 7 })
|
|
||||||
|
|
||||||
if (now < validUntil && !cookie.seen) setUpdateToastOpen(true)
|
|
||||||
}
|
|
||||||
}, [effectiveVersion?.updated_at])
|
|
||||||
|
|
||||||
function getToastCookie() {
|
|
||||||
if (appState.version && appState.version.updated_at !== '') {
|
|
||||||
const updatedAt = new Date(appState.version.updated_at)
|
|
||||||
const cookieValues = getCookie(
|
|
||||||
`update-${format(updatedAt, 'yyyy-MM-dd')}`
|
|
||||||
)
|
|
||||||
return cookieValues
|
|
||||||
? (JSON.parse(cookieValues as string) as { seen: true })
|
|
||||||
: { seen: false }
|
|
||||||
} else {
|
|
||||||
return { seen: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToastActionClicked() {
|
|
||||||
setUpdateToastOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToastClosed() {
|
|
||||||
setUpdateToastOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = pathname?.replaceAll('/', '') || ''
|
|
||||||
|
|
||||||
// Only render toast if we have valid version data with update_type
|
|
||||||
if (!['about', 'updates', 'roadmap'].includes(path) && effectiveVersion && effectiveVersion.update_type) {
|
|
||||||
return (
|
|
||||||
<UpdateToast
|
|
||||||
open={updateToastOpen}
|
|
||||||
updateType={effectiveVersion.update_type}
|
|
||||||
onActionClicked={handleToastActionClicked}
|
|
||||||
onCloseClicked={handleToastClosed}
|
|
||||||
lastUpdated={effectiveVersion.updated_at}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { appState } from '~/utils/appState'
|
|
||||||
|
|
||||||
interface VersionHydratorProps {
|
|
||||||
version: AppUpdate | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VersionHydrator({ version }: VersionHydratorProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (version && version.updated_at) {
|
|
||||||
appState.version = version
|
|
||||||
}
|
|
||||||
}, [version])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
// Minimal root layout - all content is handled in [locale]/layout.tsx
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
import axios, { AxiosRequestConfig } from "axios";
|
|
||||||
import http from "http";
|
|
||||||
import https from "https";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
// Base URL from environment variable
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SIERO_API_URL || 'https://localhost:3000/v1';
|
|
||||||
const oauthUrl = process.env.NEXT_PUBLIC_SIERO_OAUTH_URL || 'https://localhost:3000/oauth';
|
|
||||||
|
|
||||||
// Shared Axios instance with sane defaults for server-side calls
|
|
||||||
const httpClient = axios.create({
|
|
||||||
baseURL: baseUrl,
|
|
||||||
timeout: 15000,
|
|
||||||
// Keep connections alive to reduce socket churn
|
|
||||||
httpAgent: new http.Agent({ keepAlive: true, maxSockets: 50 }),
|
|
||||||
httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 50 }),
|
|
||||||
// Do not throw on HTTP status by default; let callers handle
|
|
||||||
validateStatus: () => true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Utility to get auth token from cookies on the server
|
|
||||||
export function getAuthToken() {
|
|
||||||
const cookieStore = cookies();
|
|
||||||
const accountCookie = cookieStore.get('account');
|
|
||||||
|
|
||||||
if (accountCookie) {
|
|
||||||
try {
|
|
||||||
const accountData = JSON.parse(accountCookie.value);
|
|
||||||
return accountData.token;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse account cookie', e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create headers with auth token
|
|
||||||
export function createHeaders() {
|
|
||||||
const token = getAuthToken();
|
|
||||||
|
|
||||||
return {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper for GET requests
|
|
||||||
export async function fetchFromApi(endpoint: string, config?: AxiosRequestConfig) {
|
|
||||||
const headers = createHeaders();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await httpClient.get(`${endpoint}`, {
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...headers,
|
|
||||||
...(config?.headers || {})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`API fetch error: ${endpoint}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper for POST requests
|
|
||||||
export async function postToApi(endpoint: string, data: any, config?: AxiosRequestConfig) {
|
|
||||||
const headers = createHeaders();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await httpClient.post(`${endpoint}`, data, {
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...headers,
|
|
||||||
...(config?.headers || {})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`API post error: ${endpoint}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper for PUT requests
|
|
||||||
export async function putToApi(endpoint: string, data: any, config?: AxiosRequestConfig) {
|
|
||||||
const headers = createHeaders();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await httpClient.put(`${endpoint}`, data, {
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...headers,
|
|
||||||
...(config?.headers || {})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`API put error: ${endpoint}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper for DELETE requests
|
|
||||||
export async function deleteFromApi(endpoint: string, data?: any, config?: AxiosRequestConfig) {
|
|
||||||
const headers = createHeaders();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await httpClient.delete(`${endpoint}`, {
|
|
||||||
...config,
|
|
||||||
headers: {
|
|
||||||
...headers,
|
|
||||||
...(config?.headers || {})
|
|
||||||
},
|
|
||||||
data
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`API delete error: ${endpoint}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper for login endpoint
|
|
||||||
export async function login(credentials: { email: string; password: string }) {
|
|
||||||
try {
|
|
||||||
const response = await axios.post(`${oauthUrl}/token`, credentials);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to revalidate cache for a path
|
|
||||||
export function revalidate(path: string) {
|
|
||||||
try {
|
|
||||||
revalidatePath(path);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to revalidate ${path}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schemas for validation
|
|
||||||
export const UserSchema = z.object({
|
|
||||||
username: z.string().min(3).max(20),
|
|
||||||
email: z.string().email(),
|
|
||||||
password: z.string().min(8),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const PartySchema = z.object({
|
|
||||||
name: z.string().optional(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
visibility: z.enum(['public', 'unlisted', 'private']),
|
|
||||||
raid_id: z.string().optional(),
|
|
||||||
element: z.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SearchSchema = z.object({
|
|
||||||
query: z.string(),
|
|
||||||
filters: z.record(z.array(z.number())).optional(),
|
|
||||||
job: z.string().optional(),
|
|
||||||
locale: z.string().default('en'),
|
|
||||||
page: z.number().default(0),
|
|
||||||
});
|
|
||||||
148
app/lib/data.ts
148
app/lib/data.ts
|
|
@ -1,148 +0,0 @@
|
||||||
import { fetchFromApi } from './api-utils';
|
|
||||||
|
|
||||||
// Server-side data fetching functions
|
|
||||||
// Next.js automatically deduplicates requests within the same render
|
|
||||||
|
|
||||||
// Get teams with optional filters
|
|
||||||
export async function getTeams({
|
|
||||||
element,
|
|
||||||
raid,
|
|
||||||
recency,
|
|
||||||
page = 1,
|
|
||||||
username,
|
|
||||||
}: {
|
|
||||||
element?: number;
|
|
||||||
raid?: string;
|
|
||||||
recency?: string;
|
|
||||||
page?: number;
|
|
||||||
username?: string;
|
|
||||||
}) {
|
|
||||||
const queryParams: Record<string, string> = {};
|
|
||||||
if (element) queryParams.element = element.toString();
|
|
||||||
if (raid) queryParams.raid_id = raid;
|
|
||||||
if (recency) queryParams.recency = recency;
|
|
||||||
if (page) queryParams.page = page.toString();
|
|
||||||
|
|
||||||
let endpoint = '/parties';
|
|
||||||
if (username) {
|
|
||||||
endpoint = `/users/${username}/parties`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryString = new URLSearchParams(queryParams).toString();
|
|
||||||
if (queryString) endpoint += `?${queryString}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await fetchFromApi(endpoint);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch teams', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a single team by shortcode
|
|
||||||
export async function getTeam(shortcode: string) {
|
|
||||||
try {
|
|
||||||
const data = await fetchFromApi(`/parties/${shortcode}`);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to fetch team with shortcode ${shortcode}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user info
|
|
||||||
export async function getUserInfo(username: string) {
|
|
||||||
try {
|
|
||||||
const data = await fetchFromApi(`/users/info/${username}`);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to fetch user info for ${username}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get raid groups
|
|
||||||
export async function getRaidGroups() {
|
|
||||||
try {
|
|
||||||
const data = await fetchFromApi('/raids/groups');
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch raid groups', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get version info
|
|
||||||
export async function getVersion() {
|
|
||||||
try {
|
|
||||||
const data = await fetchFromApi('/version');
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch version info', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user's favorites/saved teams
|
|
||||||
export async function getFavorites() {
|
|
||||||
try {
|
|
||||||
const data = await fetchFromApi('/parties/favorites');
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch favorites', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all jobs
|
|
||||||
export async function getJobs(element?: number) {
|
|
||||||
try {
|
|
||||||
const queryParams: Record<string, string> = {};
|
|
||||||
if (element) queryParams.element = element.toString();
|
|
||||||
|
|
||||||
let endpoint = '/jobs';
|
|
||||||
const queryString = new URLSearchParams(queryParams).toString();
|
|
||||||
if (queryString) endpoint += `?${queryString}`;
|
|
||||||
|
|
||||||
const data = await fetchFromApi(endpoint);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch jobs', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get job by ID
|
|
||||||
export async function getJob(jobId: string) {
|
|
||||||
try {
|
|
||||||
const data = await fetchFromApi(`/jobs/${jobId}`);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to fetch job with ID ${jobId}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get job skills
|
|
||||||
export async function getJobSkills(jobId?: string) {
|
|
||||||
try {
|
|
||||||
const endpoint = jobId ? `/jobs/${jobId}/skills` : '/jobs/skills';
|
|
||||||
const data = await fetchFromApi(endpoint);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch job skills', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get job accessories
|
|
||||||
export async function getJobAccessories(jobId: string) {
|
|
||||||
try {
|
|
||||||
const data = await fetchFromApi(`/jobs/${jobId}/accessories`);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to fetch accessories for job ${jobId}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { Metadata } from 'next'
|
|
||||||
|
|
||||||
// Force dynamic rendering to avoid issues
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Page not found / granblue.team',
|
|
||||||
description: 'The page you were looking for could not be found'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
return (
|
|
||||||
<div className="error-container">
|
|
||||||
<div className="error-content">
|
|
||||||
<h1>404</h1>
|
|
||||||
<h2>Page Not Found</h2>
|
|
||||||
<p>The page you're looking for doesn't exist.</p>
|
|
||||||
<div className="error-actions">
|
|
||||||
<a href="/new" className="button primary">
|
|
||||||
Create a new party
|
|
||||||
</a>
|
|
||||||
<a href="/teams" className="button secondary">
|
|
||||||
Browse teams
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
// Error page styles
|
|
||||||
.error-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: calc(100vh - 60px); // Adjust for header height
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-content {
|
|
||||||
max-width: 600px;
|
|
||||||
padding: 2rem;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
color: var(--text-color-secondary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
background-color: var(--background-color-secondary);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
|
|
||||||
.error-digest {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-color-tertiary);
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
|
|
||||||
.button {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
&.primary {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--primary-color-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.secondary {
|
|
||||||
background-color: var(--background-color-tertiary);
|
|
||||||
color: var(--text-color);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--background-color-quaternary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { getCookie } from 'cookies-next'
|
|
||||||
import { accountState } from '~utils/accountState'
|
|
||||||
import { setHeaders } from '~utils/userToken'
|
|
||||||
|
|
||||||
interface InitialAuthData {
|
|
||||||
account: {
|
|
||||||
token: string
|
|
||||||
userId: string
|
|
||||||
username: string
|
|
||||||
role: number
|
|
||||||
}
|
|
||||||
user: {
|
|
||||||
avatar: {
|
|
||||||
picture: string
|
|
||||||
element: string
|
|
||||||
}
|
|
||||||
gender: number
|
|
||||||
language: string
|
|
||||||
theme: string
|
|
||||||
bahamut?: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AccountStateInitializerProps {
|
|
||||||
initialAuthData?: InitialAuthData | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AccountStateInitializer({ initialAuthData }: AccountStateInitializerProps) {
|
|
||||||
const initialized = useRef(false)
|
|
||||||
|
|
||||||
// Initialize synchronously on first render if we have server data
|
|
||||||
if (initialAuthData && !initialized.current) {
|
|
||||||
initialized.current = true
|
|
||||||
const { account: accountData, user: userData } = initialAuthData
|
|
||||||
|
|
||||||
console.log(`Logged in as user "${accountData.username}"`)
|
|
||||||
|
|
||||||
// Set headers for API calls
|
|
||||||
setHeaders()
|
|
||||||
|
|
||||||
// Update account state
|
|
||||||
accountState.account.authorized = true
|
|
||||||
accountState.account.user = {
|
|
||||||
id: accountData.userId,
|
|
||||||
username: accountData.username,
|
|
||||||
role: accountData.role,
|
|
||||||
granblueId: '',
|
|
||||||
avatar: {
|
|
||||||
picture: userData.avatar.picture,
|
|
||||||
element: userData.avatar.element,
|
|
||||||
},
|
|
||||||
gender: userData.gender,
|
|
||||||
language: userData.language,
|
|
||||||
theme: userData.theme,
|
|
||||||
bahamut: userData.bahamut || false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only run client-side cookie reading if no server data
|
|
||||||
if (initialized.current) return
|
|
||||||
|
|
||||||
const accountCookie = getCookie('account')
|
|
||||||
const userCookie = getCookie('user')
|
|
||||||
|
|
||||||
if (accountCookie && userCookie) {
|
|
||||||
try {
|
|
||||||
const accountData = JSON.parse(accountCookie as string)
|
|
||||||
const userData = JSON.parse(userCookie as string)
|
|
||||||
|
|
||||||
if (accountData && accountData.token) {
|
|
||||||
console.log(`Logged in as user "${accountData.username}"`)
|
|
||||||
|
|
||||||
// Set headers for API calls
|
|
||||||
setHeaders()
|
|
||||||
|
|
||||||
// Update account state
|
|
||||||
accountState.account.authorized = true
|
|
||||||
accountState.account.user = {
|
|
||||||
id: accountData.userId,
|
|
||||||
username: accountData.username,
|
|
||||||
role: accountData.role,
|
|
||||||
granblueId: '',
|
|
||||||
avatar: {
|
|
||||||
picture: userData.avatar.picture,
|
|
||||||
element: userData.avatar.element,
|
|
||||||
},
|
|
||||||
gender: userData.gender,
|
|
||||||
language: userData.language,
|
|
||||||
theme: userData.theme,
|
|
||||||
bahamut: userData.bahamut || false,
|
|
||||||
}
|
|
||||||
|
|
||||||
initialized.current = true
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing account cookies:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
.group {
|
|
||||||
$height: 36px;
|
|
||||||
|
|
||||||
background-color: var(--toggle-bg);
|
|
||||||
border: 1px solid var(--toggle-stroke);
|
|
||||||
border-radius: $height;
|
|
||||||
display: flex;
|
|
||||||
height: $height;
|
|
||||||
gap: calc($unit / 4);
|
|
||||||
padding: calc($unit / 2);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
border-radius: $unit-2x;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
background: var(--toggle-bg);
|
|
||||||
border: none;
|
|
||||||
border-radius: 18px;
|
|
||||||
color: var(--input-secondary);
|
|
||||||
flex-grow: 1;
|
|
||||||
font-size: $font-regular;
|
|
||||||
padding-top: $unit;
|
|
||||||
padding-bottom: $unit;
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
border-radius: $card-corner;
|
|
||||||
padding-left: $unit-2x;
|
|
||||||
padding-right: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ja {
|
|
||||||
padding-top: 6px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&[data-state='on'] {
|
|
||||||
background: $grey-80;
|
|
||||||
color: $grey-15;
|
|
||||||
|
|
||||||
&.fire {
|
|
||||||
background: var(--fire-bg);
|
|
||||||
color: var(--fire-text-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.water {
|
|
||||||
background: var(--water-bg);
|
|
||||||
color: var(--water-text-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.earth {
|
|
||||||
background: var(--earth-bg);
|
|
||||||
color: var(--earth-text-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.wind {
|
|
||||||
background: var(--wind-bg);
|
|
||||||
color: var(--wind-text-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dark {
|
|
||||||
background: var(--dark-bg);
|
|
||||||
color: var(--dark-text-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.light {
|
|
||||||
background: var(--light-bg);
|
|
||||||
color: var(--light-text-bg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
'use client'
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { getCookie } from 'cookies-next'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
import * as ToggleGroup from '@radix-ui/react-toggle-group'
|
|
||||||
import styles from './index.module.scss'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
currentElement: number
|
|
||||||
sendValue: (value: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ElementToggle = ({ currentElement, sendValue, ...props }: Props) => {
|
|
||||||
// Localization
|
|
||||||
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
|
|
||||||
|
|
||||||
const t = useTranslations('common')
|
|
||||||
|
|
||||||
// State: Component
|
|
||||||
const [element, setElement] = useState(currentElement)
|
|
||||||
|
|
||||||
// Methods: Handlers
|
|
||||||
const handleElementChange = (value: string) => {
|
|
||||||
const newElement = parseInt(value)
|
|
||||||
setElement(newElement)
|
|
||||||
sendValue(newElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Rendering
|
|
||||||
return (
|
|
||||||
<ToggleGroup.Root
|
|
||||||
className={styles.group}
|
|
||||||
type="single"
|
|
||||||
value={`${element}`}
|
|
||||||
aria-label="Element"
|
|
||||||
onValueChange={handleElementChange}
|
|
||||||
>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
className={classNames({
|
|
||||||
[styles.item]: true,
|
|
||||||
[styles[`${locale}`]]: true,
|
|
||||||
})}
|
|
||||||
value="0"
|
|
||||||
aria-label="null"
|
|
||||||
>
|
|
||||||
{t('elements.null')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
className={classNames({
|
|
||||||
[styles.item]: true,
|
|
||||||
[styles.wind]: true,
|
|
||||||
[styles[`${locale}`]]: true,
|
|
||||||
})}
|
|
||||||
value="1"
|
|
||||||
aria-label="wind"
|
|
||||||
>
|
|
||||||
{t('elements.wind')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
className={classNames({
|
|
||||||
[styles.item]: true,
|
|
||||||
[styles.fire]: true,
|
|
||||||
[styles[`${locale}`]]: true,
|
|
||||||
})}
|
|
||||||
value="2"
|
|
||||||
aria-label="fire"
|
|
||||||
>
|
|
||||||
{t('elements.fire')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
className={classNames({
|
|
||||||
[styles.item]: true,
|
|
||||||
[styles.water]: true,
|
|
||||||
[styles[`${locale}`]]: true,
|
|
||||||
})}
|
|
||||||
value="3"
|
|
||||||
aria-label="water"
|
|
||||||
>
|
|
||||||
{t('elements.water')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
className={classNames({
|
|
||||||
[styles.item]: true,
|
|
||||||
[styles.earth]: true,
|
|
||||||
[styles[`${locale}`]]: true,
|
|
||||||
})}
|
|
||||||
value="4"
|
|
||||||
aria-label="earth"
|
|
||||||
>
|
|
||||||
{t('elements.earth')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
className={classNames({
|
|
||||||
[styles.item]: true,
|
|
||||||
[styles.dark]: true,
|
|
||||||
[styles[`${locale}`]]: true,
|
|
||||||
})}
|
|
||||||
value="5"
|
|
||||||
aria-label="dark"
|
|
||||||
>
|
|
||||||
{t('elements.dark')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
<ToggleGroup.Item
|
|
||||||
className={classNames({
|
|
||||||
[styles.item]: true,
|
|
||||||
[styles.light]: true,
|
|
||||||
[styles[`${locale}`]]: true,
|
|
||||||
})}
|
|
||||||
value="6"
|
|
||||||
aria-label="light"
|
|
||||||
>
|
|
||||||
{t('elements.light')}
|
|
||||||
</ToggleGroup.Item>
|
|
||||||
</ToggleGroup.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ElementToggle
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
.error {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit;
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 30vw;
|
|
||||||
justify-content: center;
|
|
||||||
height: 60vh;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.code {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: $font-tiny;
|
|
||||||
font-weight: $bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: $unit-4x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
|
|
||||||
import Button from '~components/common/Button'
|
|
||||||
import { ResponseStatus } from '~types'
|
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
status: ResponseStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
const ErrorSection = ({ status }: Props) => {
|
|
||||||
// Import translations
|
|
||||||
const t = useTranslations('common')
|
|
||||||
|
|
||||||
const [statusText, setStatusText] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setStatusText(status.text.replaceAll(' ', '_').toLowerCase())
|
|
||||||
}, [status.text])
|
|
||||||
|
|
||||||
const errorBody = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.code}>{status.code}</div>
|
|
||||||
<h1>{t(`errors.${statusText}.title`)}</h1>
|
|
||||||
<p>{t(`errors.${statusText}.description`)}</p>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={styles.error}>
|
|
||||||
{errorBody()}
|
|
||||||
{[401, 404].includes(status.code) ? (
|
|
||||||
<Link href="/new">
|
|
||||||
<Button text={t('errors.not_found.button')} />
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorSection
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
.bahamut {
|
|
||||||
$negative-margin: $unit * -2;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
background: #2b4683;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
gap: $unit;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: $bold;
|
|
||||||
padding: $unit-2x;
|
|
||||||
margin-top: $negative-margin;
|
|
||||||
margin-left: $negative-margin;
|
|
||||||
margin-right: $negative-margin;
|
|
||||||
margin-bottom: $unit-2x;
|
|
||||||
width: 100vw;
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 1.2em;
|
|
||||||
fill: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
margin-bottom: $unit;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
section {
|
|
||||||
display: flex;
|
|
||||||
gap: $unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
img,
|
|
||||||
.placeholder {
|
|
||||||
$diameter: 32px;
|
|
||||||
border-radius: calc($diameter / 2);
|
|
||||||
height: $diameter;
|
|
||||||
width: $diameter;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
background: var(--placeholder-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownWrapper {
|
|
||||||
display: inline-block;
|
|
||||||
padding-bottom: $unit;
|
|
||||||
|
|
||||||
&:hover .Menu,
|
|
||||||
.Menu.open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.Button {
|
|
||||||
background: var(--button-bg-hover);
|
|
||||||
color: var(--button-text-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
.Button .Text {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,406 +0,0 @@
|
||||||
'use client'
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { deleteCookie, getCookie } from 'cookies-next'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { useRouter } from '~/i18n/navigation'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
import clonedeep from 'lodash.clonedeep'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
import { accountState, initialAccountState } from '~utils/accountState'
|
|
||||||
import { appState, initialAppState } from '~utils/appState'
|
|
||||||
|
|
||||||
import Alert from '~components/common/Alert'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from '~components/common/DropdownMenuContent'
|
|
||||||
import DropdownMenuGroup from '~components/common/DropdownMenuGroup'
|
|
||||||
import DropdownMenuLabel from '~components/common/DropdownMenuLabel'
|
|
||||||
import DropdownMenuItem from '~components/common/DropdownMenuItem'
|
|
||||||
import LanguageSwitch from '~components/LanguageSwitch'
|
|
||||||
import LoginModal from '~components/auth/LoginModal'
|
|
||||||
import SignupModal from '~components/auth/SignupModal'
|
|
||||||
import AccountModal from '~components/auth/AccountModal'
|
|
||||||
import Button from '~components/common/Button'
|
|
||||||
import Tooltip from '~components/common/Tooltip'
|
|
||||||
|
|
||||||
import BahamutIcon from '~public/icons/Bahamut.svg'
|
|
||||||
import ChevronIcon from '~public/icons/Chevron.svg'
|
|
||||||
import MenuIcon from '~public/icons/Menu.svg'
|
|
||||||
import PlusIcon from '~public/icons/Add.svg'
|
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
|
||||||
|
|
||||||
const Header = () => {
|
|
||||||
// Localization
|
|
||||||
const t = useTranslations('common')
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// Locale
|
|
||||||
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
|
|
||||||
|
|
||||||
// Subscribe to account state changes
|
|
||||||
const accountSnap = useSnapshot(accountState)
|
|
||||||
|
|
||||||
// State management
|
|
||||||
const [alertOpen, setAlertOpen] = useState(false)
|
|
||||||
const [loginModalOpen, setLoginModalOpen] = useState(false)
|
|
||||||
const [signupModalOpen, setSignupModalOpen] = useState(false)
|
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
|
|
||||||
const [leftMenuOpen, setLeftMenuOpen] = useState(false)
|
|
||||||
const [rightMenuOpen, setRightMenuOpen] = useState(false)
|
|
||||||
|
|
||||||
// Methods: Event handlers (Buttons)
|
|
||||||
function handleLeftMenuButtonClicked() {
|
|
||||||
setLeftMenuOpen(!leftMenuOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRightMenuButtonClicked() {
|
|
||||||
setRightMenuOpen(!rightMenuOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Event handlers (Menus)
|
|
||||||
function handleLeftMenuOpenChange(open: boolean) {
|
|
||||||
setLeftMenuOpen(open)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRightMenuOpenChange(open: boolean) {
|
|
||||||
setRightMenuOpen(open)
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLeftMenu() {
|
|
||||||
setLeftMenuOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeRightMenu() {
|
|
||||||
setRightMenuOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Actions
|
|
||||||
function handleNewTeam(event: React.MouseEvent) {
|
|
||||||
event.preventDefault()
|
|
||||||
newTeam()
|
|
||||||
closeRightMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
// Close menu
|
|
||||||
closeRightMenu()
|
|
||||||
|
|
||||||
// Delete cookies
|
|
||||||
deleteCookie('account')
|
|
||||||
deleteCookie('user')
|
|
||||||
|
|
||||||
// Clean state
|
|
||||||
const resetState = clonedeep(initialAccountState)
|
|
||||||
Object.keys(resetState).forEach((key) => {
|
|
||||||
if (key !== 'language') accountState[key] = resetState[key]
|
|
||||||
})
|
|
||||||
|
|
||||||
router.refresh()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function newTeam() {
|
|
||||||
// Clean state
|
|
||||||
const resetState = clonedeep(initialAppState)
|
|
||||||
Object.keys(resetState).forEach((key) => {
|
|
||||||
appState[key] = resetState[key]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Push the root URL
|
|
||||||
router.push('/new')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods: Rendering
|
|
||||||
const profileImage = () => {
|
|
||||||
const user = accountSnap.account.user
|
|
||||||
if (accountSnap.account.authorized && user) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
alt={user.username}
|
|
||||||
className={`profile ${user.avatar.element}`}
|
|
||||||
srcSet={`/profile/${user.avatar.picture}.png,
|
|
||||||
/profile/${user.avatar.picture}@2x.png 2x`}
|
|
||||||
src={`/profile/${user.avatar.picture}.png`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
alt={t('header.anonymous')}
|
|
||||||
className={`profile anonymous`}
|
|
||||||
srcSet={`/profile/npc.png,
|
|
||||||
/profile/npc@2x.png 2x`}
|
|
||||||
src={`/profile/npc.png`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rendering: Buttons
|
|
||||||
const newButton = (
|
|
||||||
<Tooltip content={t('tooltips.new')}>
|
|
||||||
<Button
|
|
||||||
leftAccessoryIcon={<PlusIcon />}
|
|
||||||
className="New"
|
|
||||||
blended={true}
|
|
||||||
text={t('buttons.new')}
|
|
||||||
onClick={newTeam}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rendering: Modals
|
|
||||||
const logoutConfirmationAlert = (
|
|
||||||
<Alert
|
|
||||||
message={t('alert.confirm_logout')}
|
|
||||||
open={alertOpen}
|
|
||||||
primaryActionText="Log out"
|
|
||||||
primaryAction={logout}
|
|
||||||
cancelActionText="Nevermind"
|
|
||||||
cancelAction={() => setAlertOpen(false)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const settingsModal = (
|
|
||||||
<>
|
|
||||||
{accountSnap.account.user && (
|
|
||||||
<AccountModal
|
|
||||||
open={settingsModalOpen}
|
|
||||||
username={accountSnap.account.user.username}
|
|
||||||
picture={accountSnap.account.user.avatar.picture}
|
|
||||||
gender={accountSnap.account.user.gender}
|
|
||||||
language={accountSnap.account.user.language}
|
|
||||||
theme={accountSnap.account.user.theme}
|
|
||||||
role={accountSnap.account.user.role}
|
|
||||||
bahamutMode={
|
|
||||||
accountSnap.account.user.role === 9
|
|
||||||
? accountSnap.account.user.bahamut
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onOpenChange={setSettingsModalOpen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const loginModal = (
|
|
||||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
|
||||||
)
|
|
||||||
|
|
||||||
const signupModal = (
|
|
||||||
<SignupModal open={signupModalOpen} onOpenChange={setSignupModalOpen} />
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rendering: Compositing
|
|
||||||
const authorizedLeftItems = (
|
|
||||||
<>
|
|
||||||
{accountSnap.account.user && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<Link
|
|
||||||
href={`/${accountSnap.account.user.username}` || ''}
|
|
||||||
>
|
|
||||||
<span>{t('menu.profile')}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<Link href={`/saved` || ''}>{t('menu.saved')}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
const leftMenuItems = (
|
|
||||||
<>
|
|
||||||
{accountSnap.account.authorized &&
|
|
||||||
accountSnap.account.user &&
|
|
||||||
authorizedLeftItems}
|
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<Link href="/teams">{t('menu.teams')}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<div>
|
|
||||||
<span>{t('menu.guides')}</span>
|
|
||||||
<i className="tag">{t('coming_soon')}</i>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<a
|
|
||||||
href={locale == 'ja' ? '/ja/about' : '/about'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.about')}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<a
|
|
||||||
href={locale == 'ja' ? '/ja/updates' : '/updates'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.updates')}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
|
||||||
<a
|
|
||||||
href={locale == 'ja' ? '/ja/roadmap' : '/roadmap'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t('about.segmented_control.roadmap')}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const left = (
|
|
||||||
<section>
|
|
||||||
<div className={styles.dropdownWrapper}>
|
|
||||||
<DropdownMenu
|
|
||||||
open={leftMenuOpen}
|
|
||||||
onOpenChange={handleLeftMenuOpenChange}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
active={leftMenuOpen}
|
|
||||||
blended={true}
|
|
||||||
leftAccessoryIcon={<MenuIcon />}
|
|
||||||
onClick={handleLeftMenuButtonClicked}
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="Left">
|
|
||||||
{leftMenuItems}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
|
|
||||||
const authorizedRightItems = (
|
|
||||||
<>
|
|
||||||
{accountSnap.account.user && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
{`@${accountSnap.account.user.username}`}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem onClick={closeRightMenu}>
|
|
||||||
<Link
|
|
||||||
href={`/${accountSnap.account.user.username}` || ''}
|
|
||||||
>
|
|
||||||
<span>{t('menu.profile')}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="MenuItem"
|
|
||||||
onClick={() => setSettingsModalOpen(true)}
|
|
||||||
>
|
|
||||||
<span>{t('menu.settings')}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setAlertOpen(true)}
|
|
||||||
destructive={true}
|
|
||||||
>
|
|
||||||
<span>{t('menu.logout')}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const unauthorizedRightItems = (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem className="language">
|
|
||||||
<span>{t('menu.language')}</span>
|
|
||||||
<LanguageSwitch />
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuGroup className="MenuGroup">
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="MenuItem"
|
|
||||||
onClick={() => setLoginModalOpen(true)}
|
|
||||||
>
|
|
||||||
<span>{t('menu.login')}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="MenuItem"
|
|
||||||
onClick={() => setSignupModalOpen(true)}
|
|
||||||
>
|
|
||||||
<span>{t('menu.signup')}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const rightMenuItems = (
|
|
||||||
<>
|
|
||||||
{accountSnap.account.authorized && accountSnap.account.user
|
|
||||||
? authorizedRightItems
|
|
||||||
: unauthorizedRightItems}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const right = (
|
|
||||||
<section>
|
|
||||||
{newButton}
|
|
||||||
<DropdownMenu
|
|
||||||
open={rightMenuOpen}
|
|
||||||
onOpenChange={handleRightMenuOpenChange}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className={classNames({ Active: rightMenuOpen })}
|
|
||||||
leftAccessoryIcon={profileImage()}
|
|
||||||
rightAccessoryIcon={<ChevronIcon />}
|
|
||||||
rightAccessoryClassName="Arrow"
|
|
||||||
onClick={handleRightMenuButtonClicked}
|
|
||||||
blended={true}
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="Right">
|
|
||||||
{rightMenuItems}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{accountSnap.account.user?.bahamut && (
|
|
||||||
<div className={styles.bahamut}>
|
|
||||||
<BahamutIcon />
|
|
||||||
<p>Bahamut Mode is active</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<nav className={styles.header}>
|
|
||||||
{left}
|
|
||||||
{right}
|
|
||||||
{logoutConfirmationAlert}
|
|
||||||
{settingsModal}
|
|
||||||
{loginModal}
|
|
||||||
{signupModal}
|
|
||||||
</nav>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: calc($unit / 2);
|
|
||||||
|
|
||||||
.title {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: $unit * 2;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
flex-grow: 1;
|
|
||||||
font-size: $font-medium;
|
|
||||||
line-height: 1.2;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
height: auto;
|
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.perpetuity {
|
|
||||||
position: absolute;
|
|
||||||
background-image: url('/icons/perpetuity/filled.svg');
|
|
||||||
background-size: $unit-3x $unit-3x;
|
|
||||||
z-index: 20;
|
|
||||||
top: $unit-half * -1;
|
|
||||||
right: $unit-3x;
|
|
||||||
width: $unit-3x;
|
|
||||||
height: $unit-3x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.subInfo {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: $unit-2x;
|
|
||||||
|
|
||||||
.icons {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-grow: 0;
|
|
||||||
gap: $unit-half;
|
|
||||||
|
|
||||||
.proficiencies {
|
|
||||||
display: flex;
|
|
||||||
gap: $unit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
'use client'
|
|
||||||
import { getCookie } from 'cookies-next'
|
|
||||||
|
|
||||||
import UncapIndicator from '~components/uncap/UncapIndicator'
|
|
||||||
import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon'
|
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
gridObject: GridCharacter | GridSummon | GridWeapon
|
|
||||||
object: Character | Summon | Weapon
|
|
||||||
type: 'character' | 'summon' | 'weapon'
|
|
||||||
}
|
|
||||||
|
|
||||||
const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
|
|
||||||
const Proficiency = [
|
|
||||||
'none',
|
|
||||||
'sword',
|
|
||||||
'dagger',
|
|
||||||
'axe',
|
|
||||||
'spear',
|
|
||||||
'bow',
|
|
||||||
'staff',
|
|
||||||
'fist',
|
|
||||||
'harp',
|
|
||||||
'gun',
|
|
||||||
'katana',
|
|
||||||
]
|
|
||||||
|
|
||||||
const HovercardHeader = ({ gridObject, object, type, ...props }: Props) => {
|
|
||||||
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
|
|
||||||
|
|
||||||
const overlay = () => {
|
|
||||||
if (type === 'character') {
|
|
||||||
const gridCharacter = gridObject as GridCharacter
|
|
||||||
if (gridCharacter.perpetuity) return <i className={styles.perpetuity} />
|
|
||||||
} else if (type === 'summon') {
|
|
||||||
const gridSummon = gridObject as GridSummon
|
|
||||||
if (gridSummon.quick_summon) return <i className={styles.quickSummon} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const characterImage = () => {
|
|
||||||
const gridCharacter = gridObject as GridCharacter
|
|
||||||
const character = object as Character
|
|
||||||
|
|
||||||
// Change the image based on the uncap level
|
|
||||||
let suffix = '01'
|
|
||||||
if (gridCharacter.uncap_level == 6) suffix = '04'
|
|
||||||
else if (gridCharacter.uncap_level == 5) suffix = '03'
|
|
||||||
else if (gridCharacter.uncap_level > 2) suffix = '02'
|
|
||||||
|
|
||||||
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${character.granblue_id}_${suffix}.jpg`
|
|
||||||
}
|
|
||||||
|
|
||||||
const summonImage = () => {
|
|
||||||
const summon = object as Summon
|
|
||||||
const gridSummon = gridObject as GridSummon
|
|
||||||
|
|
||||||
const upgradedSummons = [
|
|
||||||
'2040094000',
|
|
||||||
'2040100000',
|
|
||||||
'2040080000',
|
|
||||||
'2040098000',
|
|
||||||
'2040090000',
|
|
||||||
'2040084000',
|
|
||||||
'2040003000',
|
|
||||||
'2040056000',
|
|
||||||
]
|
|
||||||
|
|
||||||
let suffix = ''
|
|
||||||
if (
|
|
||||||
upgradedSummons.indexOf(summon.granblue_id.toString()) != -1 &&
|
|
||||||
gridSummon.uncap_level == 5
|
|
||||||
) {
|
|
||||||
suffix = '_02'
|
|
||||||
} else if (
|
|
||||||
gridSummon.object.uncap.transcendence &&
|
|
||||||
gridSummon.transcendence_step > 0
|
|
||||||
) {
|
|
||||||
suffix = '_03'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the correct source for the summon
|
|
||||||
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${summon.granblue_id}${suffix}.jpg`
|
|
||||||
}
|
|
||||||
|
|
||||||
const weaponImage = () => {
|
|
||||||
const gridWeapon = gridObject as GridWeapon
|
|
||||||
const weapon = object as Weapon
|
|
||||||
|
|
||||||
if (gridWeapon.object.element == 0 && gridWeapon.element)
|
|
||||||
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}_${gridWeapon.element}.jpg`
|
|
||||||
else
|
|
||||||
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${weapon.granblue_id}.jpg`
|
|
||||||
}
|
|
||||||
|
|
||||||
const image = () => {
|
|
||||||
switch (type) {
|
|
||||||
case 'character':
|
|
||||||
return characterImage()
|
|
||||||
case 'summon':
|
|
||||||
return summonImage()
|
|
||||||
case 'weapon':
|
|
||||||
return weaponImage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const summonProficiency = (
|
|
||||||
<div className={styles.icons}>
|
|
||||||
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const weaponProficiency = (
|
|
||||||
<div className={styles.icons}>
|
|
||||||
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
|
|
||||||
{'proficiency' in object && !Array.isArray(object.proficiency) && (
|
|
||||||
<WeaponLabelIcon
|
|
||||||
labelType={Proficiency[object.proficiency]}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const characterProficiency = (
|
|
||||||
<div
|
|
||||||
className={classNames({
|
|
||||||
[styles.icons]: true,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<WeaponLabelIcon labelType={Element[object.element]} size="small" />
|
|
||||||
|
|
||||||
{'proficiency' in object && Array.isArray(object.proficiency) && (
|
|
||||||
<WeaponLabelIcon
|
|
||||||
labelType={Proficiency[object.proficiency[0]]}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{'proficiency' in object &&
|
|
||||||
Array.isArray(object.proficiency) &&
|
|
||||||
object.proficiency.length > 1 && (
|
|
||||||
<WeaponLabelIcon
|
|
||||||
labelType={Proficiency[object.proficiency[1]]}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
function proficiency() {
|
|
||||||
switch (type) {
|
|
||||||
case 'character':
|
|
||||||
return characterProficiency
|
|
||||||
case 'summon':
|
|
||||||
return summonProficiency
|
|
||||||
case 'weapon':
|
|
||||||
return weaponProficiency
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className={styles.root}>
|
|
||||||
<div className={styles.title}>
|
|
||||||
<h4>{object.name[locale]}</h4>
|
|
||||||
<div className={styles.image}>
|
|
||||||
{overlay()}
|
|
||||||
<img alt={object.name[locale]} src={image()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.subInfo}>
|
|
||||||
{proficiency()}
|
|
||||||
<UncapIndicator
|
|
||||||
className="hovercard"
|
|
||||||
type={type}
|
|
||||||
ulb={object.uncap.ulb || false}
|
|
||||||
flb={object.uncap.flb || false}
|
|
||||||
transcendenceStage={
|
|
||||||
'transcendence_step' in gridObject
|
|
||||||
? gridObject.transcendence_step
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
special={'special' in object ? object.special : false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HovercardHeader
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
.languageSwitch {
|
|
||||||
$height: 24px;
|
|
||||||
|
|
||||||
background: $grey-60;
|
|
||||||
border-radius: calc($height / 2);
|
|
||||||
border: none;
|
|
||||||
position: relative;
|
|
||||||
width: 44px;
|
|
||||||
height: $height;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb {
|
|
||||||
$diameter: 18px;
|
|
||||||
|
|
||||||
background: $grey-100;
|
|
||||||
border-radius: calc($diameter / 2);
|
|
||||||
display: block;
|
|
||||||
height: $diameter;
|
|
||||||
width: $diameter;
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
left: 3px;
|
|
||||||
z-index: 3;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-state='checked'] {
|
|
||||||
background: $grey-100;
|
|
||||||
left: 23px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.left,
|
|
||||||
.right {
|
|
||||||
color: $grey-100;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: $bold;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
top: 6px;
|
|
||||||
left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right {
|
|
||||||
top: 6px;
|
|
||||||
right: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
'use client'
|
|
||||||
import React, { PropsWithChildren, useEffect, useState } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import { setCookie } from 'cookies-next'
|
|
||||||
import { retrieveLocaleCookies } from '~utils/retrieveCookies'
|
|
||||||
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
|
||||||
import styles from './index.module.scss'
|
|
||||||
|
|
||||||
interface Props extends SwitchPrimitive.SwitchProps {}
|
|
||||||
|
|
||||||
export const LanguageSwitch = React.forwardRef<HTMLButtonElement, Props>(
|
|
||||||
function LanguageSwitch(
|
|
||||||
{ children }: PropsWithChildren<Props>,
|
|
||||||
forwardedRef
|
|
||||||
) {
|
|
||||||
// Router and locale data
|
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname()
|
|
||||||
const localeData = retrieveLocaleCookies()
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [languageChecked, setLanguageChecked] = useState(false)
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
useEffect(() => {
|
|
||||||
setLanguageChecked(localeData === 'ja' ? true : false)
|
|
||||||
}, [localeData])
|
|
||||||
|
|
||||||
function changeLanguage(value: boolean) {
|
|
||||||
const language = value ? 'ja' : 'en'
|
|
||||||
const expiresAt = new Date()
|
|
||||||
expiresAt.setDate(expiresAt.getDate() + 120)
|
|
||||||
|
|
||||||
setCookie('NEXT_LOCALE', language, { path: '/', expires: expiresAt })
|
|
||||||
router.refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SwitchPrimitive.Root
|
|
||||||
className={styles.languageSwitch}
|
|
||||||
onCheckedChange={changeLanguage}
|
|
||||||
checked={languageChecked}
|
|
||||||
ref={forwardedRef}
|
|
||||||
>
|
|
||||||
<SwitchPrimitive.Thumb className={styles.thumb} />
|
|
||||||
<span className={styles.left}>JP</span>
|
|
||||||
<span className={styles.right}>EN</span>
|
|
||||||
</SwitchPrimitive.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export default LanguageSwitch
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
'use client'
|
|
||||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import { add, format } from 'date-fns'
|
|
||||||
import { getCookie } from 'cookies-next'
|
|
||||||
|
|
||||||
import { appState } from '~utils/appState'
|
|
||||||
|
|
||||||
import TopHeader from '~components/Header'
|
|
||||||
import UpdateToast from '~components/toasts/UpdateToast'
|
|
||||||
|
|
||||||
interface Props {}
|
|
||||||
|
|
||||||
const Layout = ({ children }: PropsWithChildren<Props>) => {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const [updateToastOpen, setUpdateToastOpen] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (appState.version) {
|
|
||||||
const cookie = getToastCookie()
|
|
||||||
const now = new Date()
|
|
||||||
const updatedAt = new Date(appState.version.updated_at)
|
|
||||||
const validUntil = add(updatedAt, { days: 7 })
|
|
||||||
|
|
||||||
if (now < validUntil && !cookie.seen) setUpdateToastOpen(true)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
function getToastCookie() {
|
|
||||||
if (appState.version && appState.version.updated_at !== '') {
|
|
||||||
const updatedAt = new Date(appState.version.updated_at)
|
|
||||||
const cookieValues = getCookie(
|
|
||||||
`update-${format(updatedAt, 'yyyy-MM-dd')}`
|
|
||||||
)
|
|
||||||
return cookieValues
|
|
||||||
? (JSON.parse(cookieValues as string) as { seen: true })
|
|
||||||
: { seen: false }
|
|
||||||
} else {
|
|
||||||
return { seen: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToastActionClicked() {
|
|
||||||
setUpdateToastOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToastClosed() {
|
|
||||||
setUpdateToastOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateToast = () => {
|
|
||||||
const path = pathname?.replaceAll('/', '') || ''
|
|
||||||
|
|
||||||
return (
|
|
||||||
!['about', 'updates', 'roadmap'].includes(path) &&
|
|
||||||
appState.version && (
|
|
||||||
<UpdateToast
|
|
||||||
open={updateToastOpen}
|
|
||||||
updateType={appState.version.update_type}
|
|
||||||
onActionClicked={handleToastActionClicked}
|
|
||||||
onCloseClicked={handleToastClosed}
|
|
||||||
lastUpdated={appState.version.updated_at}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ServerAvailable = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TopHeader />
|
|
||||||
{updateToast()}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{appState.version ? ServerAvailable() : ''}
|
|
||||||
<main>{children}</main>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Layout
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
.items {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: $item-corner;
|
|
||||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1);
|
|
||||||
color: rgba(0, 0, 0, 0.8);
|
|
||||||
overflow: hidden;
|
|
||||||
padding: $unit-half;
|
|
||||||
pointer-events: all;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
align-items: center;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: $item-corner-small;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: $font-small;
|
|
||||||
font-weight: $medium;
|
|
||||||
display: flex;
|
|
||||||
gap: $unit;
|
|
||||||
margin: 0;
|
|
||||||
padding: $unit-half $unit;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&.selected {
|
|
||||||
background: var(--menu-bg-item-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.job {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: $unit-4x;
|
|
||||||
height: $unit-4x;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: $unit-3x;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
border-radius: $item-corner-small;
|
|
||||||
width: $unit-4x;
|
|
||||||
height: $unit-4x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.noResult {
|
|
||||||
padding: $unit;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
'use client'
|
|
||||||
import React, {
|
|
||||||
forwardRef,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useState,
|
|
||||||
} from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import { getCookie } from 'cookies-next'
|
|
||||||
import { SuggestionProps } from '@tiptap/suggestion'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
|
||||||
|
|
||||||
type Props = Pick<SuggestionProps, 'items' | 'command' | 'query'>
|
|
||||||
|
|
||||||
export type MentionRef = {
|
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MentionSuggestion = {
|
|
||||||
granblue_id: string
|
|
||||||
name: {
|
|
||||||
[key: string]: string
|
|
||||||
en: string
|
|
||||||
ja: string
|
|
||||||
}
|
|
||||||
type: string
|
|
||||||
element: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MentionProps extends SuggestionProps {
|
|
||||||
items: MentionSuggestion[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MentionList = forwardRef<MentionRef, Props>(
|
|
||||||
({ items, ...props }: Props, forwardedRef) => {
|
|
||||||
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
|
|
||||||
|
|
||||||
const t = useTranslations('common')
|
|
||||||
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
||||||
|
|
||||||
const selectItem = (index: number) => {
|
|
||||||
const item = items[index]
|
|
||||||
|
|
||||||
if (item) {
|
|
||||||
props.command({ id: item })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const upHandler = () => {
|
|
||||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
const downHandler = () => {
|
|
||||||
setSelectedIndex((selectedIndex + 1) % items.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
const enterHandler = () => {
|
|
||||||
selectItem(selectedIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => setSelectedIndex(0), [items])
|
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
|
||||||
onKeyDown: ({ event }) => {
|
|
||||||
if (event.key === 'ArrowUp') {
|
|
||||||
upHandler()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'ArrowDown') {
|
|
||||||
downHandler()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
enterHandler()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.items}>
|
|
||||||
{items.length ? (
|
|
||||||
items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
className={classNames({
|
|
||||||
[styles.item]: true,
|
|
||||||
[styles.selected]: index === selectedIndex,
|
|
||||||
})}
|
|
||||||
key={index}
|
|
||||||
onClick={() => selectItem(index)}
|
|
||||||
>
|
|
||||||
<div className={styles[item.type]}>
|
|
||||||
<img
|
|
||||||
alt={item.name[locale]}
|
|
||||||
src={
|
|
||||||
item.type === 'character'
|
|
||||||
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/${item.type}-square/${item.granblue_id}_01.jpg`
|
|
||||||
: item.type === 'job'
|
|
||||||
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-icons/${item.granblue_id}.png`
|
|
||||||
: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/${item.type}-square/${item.granblue_id}.jpg`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span>{item.name[locale]}</span>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className={styles.noResult}>
|
|
||||||
{props.query.length < 3
|
|
||||||
? t('search.errors.type')
|
|
||||||
: t('search.errors.no_results_generic')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
MentionList.displayName = 'MentionList'
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import Head from 'next/head'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
page: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const AboutHead = ({ page }: Props) => {
|
|
||||||
// Import translations
|
|
||||||
const t = useTranslations('common')
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [currentPage, setCurrentPage] = useState('about')
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(page)
|
|
||||||
}, [page])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Head>
|
|
||||||
{/* HTML */}
|
|
||||||
<title>{t(`page.titles.${currentPage}`)}</title>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content={t(`page.descriptions.${currentPage}`)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<link rel="icon" type="image/x-icon" href="/images/favicon.png" />
|
|
||||||
|
|
||||||
{/* OpenGraph */}
|
|
||||||
<meta property="og:title" content={t(`page.titles.${currentPage}`)} />
|
|
||||||
<meta property="og:description" content={t('page.descriptions.about')} />
|
|
||||||
<meta
|
|
||||||
property="og:url"
|
|
||||||
content={`https://app.granblue.team/${currentPage}`}
|
|
||||||
/>
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
|
|
||||||
{/* Twitter */}
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta property="twitter:domain" content="app.granblue.team" />
|
|
||||||
<meta name="twitter:title" content={t(`page.titles.${currentPage}`)} />
|
|
||||||
<meta
|
|
||||||
name="twitter:description"
|
|
||||||
content={t(`page.descriptions.${currentPage}`)}
|
|
||||||
/>
|
|
||||||
</Head>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AboutHead
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
.about {
|
|
||||||
$width: 520px;
|
|
||||||
padding-bottom: $unit-12x;
|
|
||||||
|
|
||||||
section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
gap: $unit-2x;
|
|
||||||
z-index: 5;
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
position: absolute;
|
|
||||||
width: 40vw;
|
|
||||||
height: 80vh;
|
|
||||||
right: -18vw;
|
|
||||||
top: $unit-4x * -1;
|
|
||||||
z-index: 1;
|
|
||||||
background-image: var(--hero-gradient), url('/images/about-hero.jpg');
|
|
||||||
|
|
||||||
@include breakpoint(tablet) {
|
|
||||||
right: -14vw;
|
|
||||||
width: 60vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
right: $unit-2x * -1;
|
|
||||||
width: 80vw;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: ' ';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: var(--hero-gradient-overlay);
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: $font-medium;
|
|
||||||
max-width: $width;
|
|
||||||
line-height: 1.35;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-weight: $bold;
|
|
||||||
font-size: $font-medium;
|
|
||||||
margin: 0;
|
|
||||||
max-width: $width;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.links {
|
|
||||||
display: grid;
|
|
||||||
gap: $unit;
|
|
||||||
margin: $unit-2x 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
import LinkItem from '../LinkItem'
|
|
||||||
|
|
||||||
import DiscordIcon from '~public/icons/discord.svg'
|
|
||||||
import GithubIcon from '~public/icons/github.svg'
|
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
|
||||||
|
|
||||||
interface Props {}
|
|
||||||
|
|
||||||
const AboutPage: React.FC<Props> = (props: Props) => {
|
|
||||||
const common = useTranslations('common')
|
|
||||||
const about = useTranslations('about')
|
|
||||||
|
|
||||||
const classes = classNames(styles.about, 'PageContent')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes}>
|
|
||||||
<h1>{common('about.segmented_control.about')}</h1>
|
|
||||||
<section>
|
|
||||||
<h2>
|
|
||||||
{about.rich('about.subtitle', {
|
|
||||||
gameLink: (chunks) => (
|
|
||||||
<a
|
|
||||||
href="https://game.granbluefantasy.jp"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</h2>
|
|
||||||
<p>{about('about.explanation.0')}</p>
|
|
||||||
<p>{about('about.explanation.1')}</p>
|
|
||||||
<div className={styles.hero} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>{about('about.feedback.title')}</h2>
|
|
||||||
<p>{about('about.feedback.explanation')}</p>
|
|
||||||
<p>{about('about.feedback.solicit')}</p>
|
|
||||||
<LinkItem
|
|
||||||
className="discord constrained"
|
|
||||||
title="granblue-tools"
|
|
||||||
link="https://discord.gg/qyZ5hGdPC8"
|
|
||||||
icon={<DiscordIcon />}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>{about('about.credits.title')}</h2>
|
|
||||||
<p>
|
|
||||||
{about.rich('about.credits.maintainer', {
|
|
||||||
link: (chunks) => (
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/jedmund"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{about.rich('about.credits.assistance', {
|
|
||||||
link1: (chunks) => (
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/lalalalinna"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
link2: (chunks) => (
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/tarngerine"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{about.rich('about.credits.support', {
|
|
||||||
link: (chunks) => (
|
|
||||||
<a
|
|
||||||
href="https://game.granbluefantasy.jp/#guild/detail/1190185"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>{about('about.contributing.title')}</h2>
|
|
||||||
|
|
||||||
<p>{about('about.contributing.explanation')}</p>
|
|
||||||
<div className={styles.links}>
|
|
||||||
<LinkItem
|
|
||||||
className="github constrained"
|
|
||||||
title="jedmund/hensei-api"
|
|
||||||
link="https://github.com/jedmund/hensei-api"
|
|
||||||
icon={<GithubIcon />}
|
|
||||||
/>
|
|
||||||
<LinkItem
|
|
||||||
className="github constrained"
|
|
||||||
title="jedmund/hensei-web"
|
|
||||||
link="https://github.com/jedmund/hensei-web"
|
|
||||||
icon={<GithubIcon />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h2>{about('about.license.title')}</h2>
|
|
||||||
<p>
|
|
||||||
{about.rich('about.license.license', {
|
|
||||||
link: (chunks) => (
|
|
||||||
<a
|
|
||||||
href="https://choosealicense.com/licenses/agpl-3.0/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{chunks}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p>{about('about.license.explanation')}</p>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h2>{about('about.copyright.title')}</h2>
|
|
||||||
<p>{about('about.copyright.explanation')}</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AboutPage
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
.unit {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit;
|
|
||||||
|
|
||||||
img {
|
|
||||||
border-radius: $input-corner;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: $font-small;
|
|
||||||
font-weight: $medium;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
'use client'
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { getCookie } from 'cookies-next'
|
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string
|
|
||||||
type: 'character' | 'summon' | 'weapon' | 'raid' | 'job'
|
|
||||||
image?: '01' | '02' | '03' | '04'
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
active: false,
|
|
||||||
blended: false,
|
|
||||||
contained: false,
|
|
||||||
buttonSize: 'medium' as const,
|
|
||||||
image: '01',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChangelogUnit = ({ id, type, image }: Props) => {
|
|
||||||
// Locale
|
|
||||||
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [item, setItem] = useState<Character | Weapon | Summon>()
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
useEffect(() => {
|
|
||||||
fetchItem()
|
|
||||||
}, [id, type])
|
|
||||||
|
|
||||||
async function fetchItem() {
|
|
||||||
try {
|
|
||||||
let endpoint = ''
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'character':
|
|
||||||
endpoint = `/api/characters/${id}`
|
|
||||||
break
|
|
||||||
case 'weapon':
|
|
||||||
endpoint = `/api/weapons/${id}`
|
|
||||||
break
|
|
||||||
case 'summon':
|
|
||||||
endpoint = `/api/summons/${id}`
|
|
||||||
break
|
|
||||||
case 'raid':
|
|
||||||
endpoint = `/api/raids/${id}`
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(endpoint)
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
setItem(data)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error fetching ${type} ${id}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrl = () => {
|
|
||||||
let src = ''
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'character':
|
|
||||||
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-grid/${id}_${image}.jpg`
|
|
||||||
break
|
|
||||||
case 'weapon':
|
|
||||||
src =
|
|
||||||
image === '03'
|
|
||||||
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${id}_${image}.jpg`
|
|
||||||
: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${id}.jpg`
|
|
||||||
break
|
|
||||||
case 'summon':
|
|
||||||
src =
|
|
||||||
image === '04'
|
|
||||||
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${id}_${image}.jpg`
|
|
||||||
: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${id}.jpg`
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'raid':
|
|
||||||
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/raids/${id}.png`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return src
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.unit} key={id}>
|
|
||||||
<img alt={item ? item.name[locale] : ''} src={imageUrl()} />
|
|
||||||
<h4>{item ? item.name[locale] : ''}</h4>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ChangelogUnit.defaultProps = defaultProps
|
|
||||||
|
|
||||||
export default ChangelogUnit
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
.content.version {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-2x;
|
|
||||||
|
|
||||||
.header {
|
|
||||||
align-items: baseline;
|
|
||||||
display: flex;
|
|
||||||
gap: $unit-half;
|
|
||||||
margin-bottom: $unit-2x;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
color: var(--accent-yellow);
|
|
||||||
font-weight: $medium;
|
|
||||||
font-size: $font-large;
|
|
||||||
}
|
|
||||||
|
|
||||||
time {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: $font-small;
|
|
||||||
font-weight: $medium;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.contents {
|
|
||||||
margin-bottom: $unit-3x;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: $unit-4x;
|
|
||||||
|
|
||||||
.characters,
|
|
||||||
.weapons,
|
|
||||||
.summons,
|
|
||||||
.raids {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto 1fr;
|
|
||||||
gap: $unit;
|
|
||||||
|
|
||||||
& > h4 {
|
|
||||||
font-weight: $medium;
|
|
||||||
font-size: $font-regular;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
||||||
gap: $unit-4x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notes {
|
|
||||||
h4 {
|
|
||||||
font-weight: $medium;
|
|
||||||
font-size: $font-regular;
|
|
||||||
margin-bottom: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
color: var(--text-primary);
|
|
||||||
list-style-type: disc;
|
|
||||||
list-style-position: inside;
|
|
||||||
gap: $unit-half;
|
|
||||||
|
|
||||||
li {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
import ChangelogUnit from '~components/about/ChangelogUnit'
|
|
||||||
import styles from './index.module.scss'
|
|
||||||
|
|
||||||
interface UpdateObject {
|
|
||||||
character?: string[]
|
|
||||||
summon?: string[]
|
|
||||||
weapon?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
version: string
|
|
||||||
dateString: string
|
|
||||||
event: string
|
|
||||||
newItems?: UpdateObject
|
|
||||||
uncappedItems?: UpdateObject
|
|
||||||
transcendedItems?: UpdateObject
|
|
||||||
awakenedItems?: string[]
|
|
||||||
raidItems?: string[]
|
|
||||||
numNotes: number
|
|
||||||
}
|
|
||||||
const ContentUpdate = ({
|
|
||||||
version,
|
|
||||||
dateString,
|
|
||||||
event,
|
|
||||||
newItems,
|
|
||||||
uncappedItems,
|
|
||||||
transcendedItems,
|
|
||||||
awakenedItems,
|
|
||||||
raidItems,
|
|
||||||
numNotes,
|
|
||||||
}: Props) => {
|
|
||||||
const updates = useTranslations('updates')
|
|
||||||
|
|
||||||
const date = new Date(dateString)
|
|
||||||
|
|
||||||
function newItemElements(key: 'character' | 'weapon' | 'summon') {
|
|
||||||
let elements: React.ReactNode[] = []
|
|
||||||
if (newItems && newItems[key]) {
|
|
||||||
const items = newItems[key]
|
|
||||||
elements = items
|
|
||||||
? items.map((id, i) => {
|
|
||||||
return <ChangelogUnit id={id} type={key} key={`${key}-${i}`} />
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements
|
|
||||||
}
|
|
||||||
|
|
||||||
function newItemSection(key: 'character' | 'weapon' | 'summon') {
|
|
||||||
let section: React.ReactNode = ''
|
|
||||||
|
|
||||||
if (newItems && newItems[key]) {
|
|
||||||
const items = newItems[key]
|
|
||||||
section =
|
|
||||||
items && items.length > 0 ? (
|
|
||||||
<section className={styles[`${key}s`]}>
|
|
||||||
<h4>{updates(`labels.${key}s`)}</h4>
|
|
||||||
<div className={styles.items}>{newItemElements(key)}</div>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return section
|
|
||||||
}
|
|
||||||
|
|
||||||
function uncapItemElements(key: 'character' | 'weapon' | 'summon') {
|
|
||||||
let elements: React.ReactNode[] = []
|
|
||||||
if (uncappedItems && uncappedItems[key]) {
|
|
||||||
const items = uncappedItems[key]
|
|
||||||
elements = items
|
|
||||||
? items.map((id) => {
|
|
||||||
return key === 'character' ? (
|
|
||||||
<ChangelogUnit id={id} type={key} key={id} image="03" />
|
|
||||||
) : (
|
|
||||||
<ChangelogUnit id={id} type={key} key={id} />
|
|
||||||
)
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
return elements
|
|
||||||
}
|
|
||||||
|
|
||||||
function uncapItemSection(key: 'character' | 'weapon' | 'summon') {
|
|
||||||
let section: React.ReactNode = ''
|
|
||||||
|
|
||||||
if (uncappedItems && uncappedItems[key]) {
|
|
||||||
const items = uncappedItems[key]
|
|
||||||
section =
|
|
||||||
items && items.length > 0 ? (
|
|
||||||
<section className={styles[`${key}s`]}>
|
|
||||||
<h4>{updates(`labels.uncaps.${key}s`)}</h4>
|
|
||||||
<div className={styles.items}>{uncapItemElements(key)}</div>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return section
|
|
||||||
}
|
|
||||||
|
|
||||||
function transcendItemElements(key: 'character' | 'weapon' | 'summon') {
|
|
||||||
let elements: React.ReactNode[] = []
|
|
||||||
if (transcendedItems && transcendedItems[key]) {
|
|
||||||
const items = transcendedItems[key]
|
|
||||||
elements = items
|
|
||||||
? items.map((id) => {
|
|
||||||
return key === 'character' || key === 'summon' ? (
|
|
||||||
<ChangelogUnit id={id} type={key} key={id} image="04" />
|
|
||||||
) : (
|
|
||||||
<ChangelogUnit id={id} type={key} key={id} image="03" />
|
|
||||||
)
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
return elements
|
|
||||||
}
|
|
||||||
|
|
||||||
function transcendItemSection(key: 'character' | 'weapon' | 'summon') {
|
|
||||||
let section: React.ReactNode = ''
|
|
||||||
|
|
||||||
if (transcendedItems && transcendedItems[key]) {
|
|
||||||
const items = transcendedItems[key]
|
|
||||||
section =
|
|
||||||
items && items.length > 0 ? (
|
|
||||||
<section className={styles[`${key}s`]}>
|
|
||||||
<h4>{updates(`labels.transcends.${key}s`)}</h4>
|
|
||||||
<div className={styles.items}>{transcendItemElements(key)}</div>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return section
|
|
||||||
}
|
|
||||||
|
|
||||||
function newRaidSection() {
|
|
||||||
let section: React.ReactNode = ''
|
|
||||||
|
|
||||||
if (raidItems) {
|
|
||||||
section = raidItems && raidItems.length > 0 && (
|
|
||||||
<section className={styles['raids']}>
|
|
||||||
<h4>{updates(`labels.raids`)}</h4>
|
|
||||||
<div className={styles.items}>{raidItemElements()}</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return section
|
|
||||||
}
|
|
||||||
|
|
||||||
function awakenedItemElements() {
|
|
||||||
let elements: React.ReactNode[] = []
|
|
||||||
if (awakenedItems) {
|
|
||||||
elements = awakenedItems.map((id) => {
|
|
||||||
return <ChangelogUnit id={id} type="weapon" key={id} />
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return elements
|
|
||||||
}
|
|
||||||
|
|
||||||
function awakenedItemSection() {
|
|
||||||
let section: React.ReactNode = ''
|
|
||||||
|
|
||||||
if (awakenedItems && awakenedItems.length > 0) {
|
|
||||||
section = (
|
|
||||||
<section className={styles['weapons']}>
|
|
||||||
<h4>{updates(`labels.awakened.weapons`)}</h4>
|
|
||||||
<div className={styles.items}>{awakenedItemElements()}</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return section
|
|
||||||
}
|
|
||||||
|
|
||||||
function raidItemElements() {
|
|
||||||
let elements: React.ReactNode[] = []
|
|
||||||
|
|
||||||
if (raidItems) {
|
|
||||||
elements = raidItems.map((id) => {
|
|
||||||
return <ChangelogUnit id={id} type="raid" key={id} />
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={classNames({
|
|
||||||
[styles.content]: true,
|
|
||||||
[styles.version]: true,
|
|
||||||
})}
|
|
||||||
data-version={version}
|
|
||||||
>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<h3>{`${updates('events.date', {
|
|
||||||
year: date.getFullYear(),
|
|
||||||
month: `${date.getMonth() + 1}`.padStart(2, '0'),
|
|
||||||
})} ${updates(event)}`}</h3>
|
|
||||||
<time>{dateString}</time>
|
|
||||||
</div>
|
|
||||||
<div className={styles.contents}>
|
|
||||||
{newItemSection('character')}
|
|
||||||
{uncapItemSection('character')}
|
|
||||||
{transcendItemSection('character')}
|
|
||||||
{newItemSection('weapon')}
|
|
||||||
{uncapItemSection('weapon')}
|
|
||||||
{transcendItemSection('weapon')}
|
|
||||||
{newItemSection('summon')}
|
|
||||||
{uncapItemSection('summon')}
|
|
||||||
{transcendItemSection('summon')}
|
|
||||||
{awakenedItemSection()}
|
|
||||||
{newRaidSection()}
|
|
||||||
</div>
|
|
||||||
{numNotes > 0 ? (
|
|
||||||
<div className={styles.notes}>
|
|
||||||
<section>
|
|
||||||
<h4>{updates('labels.updates')}</h4>
|
|
||||||
<ul className={styles.list}>
|
|
||||||
{[...Array(numNotes)].map((e, i) => (
|
|
||||||
<li key={`${version}-${i}`}>
|
|
||||||
{updates(`versions.${version}.features.${i}`)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ContentUpdate.defaultProps = {
|
|
||||||
numNotes: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ContentUpdate
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
.item {
|
|
||||||
$diameter: $unit-6x;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--dialog-bg);
|
|
||||||
border: 1px solid var(--link-item-bg);
|
|
||||||
border-radius: $card-corner;
|
|
||||||
display: flex;
|
|
||||||
min-height: 82px;
|
|
||||||
transition: background $duration-zoom ease-in,
|
|
||||||
transform $duration-zoom ease-in;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--link-item-bg);
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
.shareIcon {
|
|
||||||
fill: var(--text-primary);
|
|
||||||
transform: translate($unit-half, calc(($unit * -1) / 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.constrained {
|
|
||||||
max-width: 520px;
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
max-width: inherit;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.constrained.update {
|
|
||||||
max-width: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.github:hover .left .icon svg {
|
|
||||||
fill: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.discord:hover .left .icon svg {
|
|
||||||
fill: #5865f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: $unit-2x;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left {
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
gap: $unit-2x;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-weight: 600;
|
|
||||||
max-width: 70%;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
fill: var(--link-item-image-color);
|
|
||||||
width: $diameter;
|
|
||||||
height: auto;
|
|
||||||
transition: fill $duration-zoom ease-in;
|
|
||||||
|
|
||||||
&.shareIcon {
|
|
||||||
width: $unit-4x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-weight: $bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { ComponentProps } from 'react'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
|
|
||||||
import ShareIcon from '~public/icons/Share.svg'
|
|
||||||
import styles from './index.module.scss'
|
|
||||||
|
|
||||||
interface Props extends ComponentProps<'div'> {
|
|
||||||
title: string
|
|
||||||
link: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const LinkItem = ({ icon, title, link, className, ...props }: Props) => {
|
|
||||||
const classes = classNames(
|
|
||||||
{
|
|
||||||
[styles.item]: true,
|
|
||||||
},
|
|
||||||
className?.split(' ').map((c) => styles[c])
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes}>
|
|
||||||
<a href={link} target="_blank" rel="noreferrer">
|
|
||||||
<div className={styles.left}>
|
|
||||||
<i className={styles.icon}>{icon}</i>
|
|
||||||
<h3>{title}</h3>
|
|
||||||
</div>
|
|
||||||
<ShareIcon className={styles.shareIcon} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LinkItem
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
.roadmap {
|
|
||||||
padding-bottom: $unit-12x;
|
|
||||||
|
|
||||||
h3.priority {
|
|
||||||
font-weight: $medium;
|
|
||||||
font-size: $font-large;
|
|
||||||
margin-bottom: $unit-4x;
|
|
||||||
|
|
||||||
&.in_progress {
|
|
||||||
color: $yellow;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.high {
|
|
||||||
color: $red;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.mid {
|
|
||||||
color: $orange-10;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.low {
|
|
||||||
color: $blue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.notes {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit;
|
|
||||||
margin-bottom: $unit-2x;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: $unit;
|
|
||||||
font-size: $font-medium;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
color: var(--text-primary);
|
|
||||||
list-style-type: none;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: $unit-3x;
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit;
|
|
||||||
margin-bottom: $unit-2x;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: $font-medium;
|
|
||||||
font-weight: $bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: $font-regular;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue