Compare commits
10 commits
main
...
13-weapon-
| Author | SHA1 | Date | |
|---|---|---|---|
| 50a58dc47a | |||
| 5a41d503d0 | |||
| 36408ede7e | |||
| d5b7fc584c | |||
| 4c6830f049 | |||
| d35cedaf04 | |||
| b53a261866 | |||
| ca0d14b5d6 | |||
| b1236a1f97 | |||
| 3690bcf2a5 |
595 changed files with 20795 additions and 50528 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
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals",
|
"extends": "next/core-web-vitals",
|
||||||
"rules": {
|
"rules": {
|
||||||
|
// Other rules
|
||||||
"@next/next/no-img-element": "off"
|
"@next/next/no-img-element": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -49,18 +49,13 @@ dist/
|
||||||
# Instructions will be provided to download these from the game
|
# Instructions will be provided to download these from the game
|
||||||
public/images/weapon*
|
public/images/weapon*
|
||||||
public/images/summon*
|
public/images/summon*
|
||||||
public/images/character*
|
public/images/chara*
|
||||||
public/images/job*
|
public/images/job*
|
||||||
public/images/awakening*
|
public/images/awakening*
|
||||||
public/images/ax*
|
public/images/ax*
|
||||||
public/images/accessory*
|
public/images/accessory*
|
||||||
public/images/mastery*
|
public/images/mastery*
|
||||||
public/images/updates*
|
public/images/updates*
|
||||||
public/images/guidebooks*
|
|
||||||
public/images/raids*
|
|
||||||
public/images/gacha*
|
|
||||||
public/images/previews*
|
|
||||||
public/image/profiles*
|
|
||||||
|
|
||||||
# Typescript v1 declaration files
|
# Typescript v1 declaration files
|
||||||
typings/
|
typings/
|
||||||
|
|
@ -88,7 +83,3 @@ typings/
|
||||||
# DS_Store
|
# DS_Store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
codebase.md
|
|
||||||
|
|
||||||
# PRDs
|
|
||||||
prd/
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[tools]
|
|
||||||
node = "20.12.0"
|
|
||||||
1
.nvmrc
1
.nvmrc
|
|
@ -1 +0,0 @@
|
||||||
20
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import type { StorybookConfig } from '@storybook/nextjs'
|
|
||||||
const path = require('path')
|
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
|
||||||
stories: [
|
|
||||||
'../components/**/*.mdx',
|
|
||||||
'../components/**/*.stories.@(js|jsx|ts|tsx)',
|
|
||||||
],
|
|
||||||
addons: [
|
|
||||||
'@storybook/addon-links',
|
|
||||||
'@storybook/addon-essentials',
|
|
||||||
'@storybook/addon-interactions',
|
|
||||||
{
|
|
||||||
name: '@storybook/addon-styling',
|
|
||||||
options: {
|
|
||||||
sass: {
|
|
||||||
// Require your Sass preprocessor here
|
|
||||||
implementation: require('sass'),
|
|
||||||
additionalData: `
|
|
||||||
@import "./styles/variables.scss";
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
staticDirs: ['../public'],
|
|
||||||
framework: {
|
|
||||||
name: '@storybook/nextjs',
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
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
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import type { Preview } from '@storybook/react'
|
|
||||||
|
|
||||||
import '../styles/globals.scss'
|
|
||||||
|
|
||||||
const preview: Preview = {
|
|
||||||
parameters: {
|
|
||||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
|
||||||
controls: {
|
|
||||||
matchers: {
|
|
||||||
color: /(background|color)$/i,
|
|
||||||
date: /Date$/,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default preview
|
|
||||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
|
|
@ -1,5 +1,3 @@
|
||||||
{
|
{
|
||||||
"git.ignoreLimitWarning": true,
|
"git.ignoreLimitWarning": true
|
||||||
"i18n-ally.localesPaths": ["public/locales"],
|
}
|
||||||
"i18n-ally.keystyle": "nested"
|
|
||||||
}
|
|
||||||
28
CLAUDE.md
28
CLAUDE.md
|
|
@ -1,28 +0,0 @@
|
||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Build and Development Commands
|
|
||||||
- `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
|
|
||||||
- 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
|
|
||||||
- Use the latest versions for Next.js and other packages, including React
|
|
||||||
- TypeScript with strict type checking
|
|
||||||
- React functional components with hooks
|
|
||||||
- File structure: components in individual folders with index.tsx and index.module.scss
|
|
||||||
- Imports: Absolute imports with ~ prefix (e.g., `~components/Layout`)
|
|
||||||
- Formatting: 2 spaces, single quotes, no semicolons (Prettier config)
|
|
||||||
- CSS: SCSS modules with BEM-style naming
|
|
||||||
- State management: Mix of local state with React hooks and global state with Valtio
|
|
||||||
- Internationalization: next-i18next with English and Japanese support
|
|
||||||
- Variable/function naming: camelCase for variables/functions, PascalCase for components
|
|
||||||
- Error handling: Try to use type checking to prevent errors where possible
|
|
||||||
12
README.md
12
README.md
|
|
@ -54,24 +54,18 @@ root
|
||||||
├─ accessory-square/
|
├─ accessory-square/
|
||||||
├─ awakening/
|
├─ awakening/
|
||||||
├─ ax/
|
├─ ax/
|
||||||
├─ character-main/
|
├─ chara-main/
|
||||||
├─ character-grid/
|
├─ chara-grid/
|
||||||
├─ character-square/
|
├─ chara-square/
|
||||||
├─ guidebooks/
|
|
||||||
├─ jobs/
|
├─ jobs/
|
||||||
├─ job-icons/
|
├─ job-icons/
|
||||||
├─ job-portraits/
|
|
||||||
├─ job-skills/
|
├─ job-skills/
|
||||||
├─ labels/
|
|
||||||
├─ mastery/
|
├─ mastery/
|
||||||
├─ placeholders/
|
|
||||||
├─ raids/
|
|
||||||
├─ summon-main/
|
├─ summon-main/
|
||||||
├─ summon-grid/
|
├─ summon-grid/
|
||||||
├─ summon-square/
|
├─ summon-square/
|
||||||
├─ updates/
|
├─ updates/
|
||||||
├─ weapon-main/
|
├─ weapon-main/
|
||||||
├─ weapon-grid/
|
├─ weapon-grid/
|
||||||
├─ weapon-keys/
|
|
||||||
├─ weapon-square/
|
├─ weapon-square/
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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,6 +1,6 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: string
|
page: string
|
||||||
|
|
@ -8,7 +8,7 @@ interface Props {
|
||||||
|
|
||||||
const AboutHead = ({ page }: Props) => {
|
const AboutHead = ({ page }: Props) => {
|
||||||
// Import translations
|
// Import translations
|
||||||
const t = useTranslations('common')
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [currentPage, setCurrentPage] = useState('about')
|
const [currentPage, setCurrentPage] = useState('about')
|
||||||
|
|
@ -26,7 +26,7 @@ const AboutHead = ({ page }: Props) => {
|
||||||
name="description"
|
name="description"
|
||||||
content={t(`page.descriptions.${currentPage}`)}
|
content={t(`page.descriptions.${currentPage}`)}
|
||||||
/>
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/x-icon" href="/images/favicon.png" />
|
<link rel="icon" type="image/x-icon" href="/images/favicon.png" />
|
||||||
|
|
||||||
{/* OpenGraph */}
|
{/* OpenGraph */}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
.about {
|
.About.PageContent {
|
||||||
$width: 520px;
|
$width: 520px;
|
||||||
padding-bottom: $unit-12x;
|
padding-bottom: $unit-12x;
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
|
|
||||||
.hero {
|
.Hero {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 40vw;
|
width: 40vw;
|
||||||
height: 80vh;
|
height: 80vh;
|
||||||
|
|
@ -55,10 +55,22 @@
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.Links {
|
||||||
.links {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
margin: $unit-2x 0;
|
margin: $unit-2x 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.LinkItem {
|
||||||
|
margin-top: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.LinkItem {
|
||||||
|
max-width: calc($width / 3 * 2);
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
max-width: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
175
components/AboutPage/index.tsx
Normal file
175
components/AboutPage/index.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Trans, useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
|
import ShareIcon from '~public/icons/Share.svg'
|
||||||
|
import DiscordIcon from '~public/icons/discord.svg'
|
||||||
|
import GithubIcon from '~public/icons/github.svg'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props {}
|
||||||
|
|
||||||
|
const AboutPage: React.FC<Props> = (props: Props) => {
|
||||||
|
const { t: common } = useTranslation('common')
|
||||||
|
const { t: about } = useTranslation('about')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="About PageContent">
|
||||||
|
<h1>{common('about.segmented_control.about')}</h1>
|
||||||
|
<section>
|
||||||
|
<h2>
|
||||||
|
<Trans i18nKey="about:about.subtitle">
|
||||||
|
Granblue.team is a tool to save and share team compositions for{' '}
|
||||||
|
<a
|
||||||
|
href="https://game.granbluefantasy.jp"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Granblue Fantasy
|
||||||
|
</a>
|
||||||
|
, a social RPG from Cygames.
|
||||||
|
</Trans>
|
||||||
|
</h2>
|
||||||
|
<p>{about('about.explanation.0')}</p>
|
||||||
|
<p>{about('about.explanation.1')}</p>
|
||||||
|
<div className="Hero" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>{about('about.feedback.title')}</h2>
|
||||||
|
<p>{about('about.feedback.explanation')}</p>
|
||||||
|
<p>{about('about.feedback.solicit')}</p>
|
||||||
|
<div className="Discord LinkItem">
|
||||||
|
<Link href="https://discord.gg/qyZ5hGdPC8">
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/qyZ5hGdPC8"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<div className="Left">
|
||||||
|
<DiscordIcon />
|
||||||
|
<h3>granblue-tools</h3>
|
||||||
|
</div>
|
||||||
|
<ShareIcon className="ShareIcon" />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>{about('about.credits.title')}</h2>
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="about:about.credits.maintainer">
|
||||||
|
Granblue.team was built and is maintained by{' '}
|
||||||
|
<a
|
||||||
|
href="https://twitter.com/jedmund"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
@jedmund
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="about:about.credits.assistance">
|
||||||
|
Many thanks to{' '}
|
||||||
|
<a
|
||||||
|
href="https://twitter.com/lalalalinna"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
@lalalalinna
|
||||||
|
</a>{' '}
|
||||||
|
and{' '}
|
||||||
|
<a
|
||||||
|
href="https://twitter.com/tarngerine"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
@tarngerine
|
||||||
|
</a>
|
||||||
|
, who both provided a lot of help and advice as I was ramping up.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="about:about.credits.support">
|
||||||
|
Many thanks also go to everyone in{' '}
|
||||||
|
<a
|
||||||
|
href="https://game.granbluefantasy.jp/#guild/detail/1190185"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Fireplace
|
||||||
|
</a>{' '}
|
||||||
|
and the granblue-tools Discord for all of their help with with bug
|
||||||
|
testing, feature requests, and moral support. (P.S. We're
|
||||||
|
recruiting!)
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>{about('about.contributing.title')}</h2>
|
||||||
|
|
||||||
|
<p>{about('about.contributing.explanation')}</p>
|
||||||
|
<ul className="Links">
|
||||||
|
<li className="Github LinkItem">
|
||||||
|
<Link href="https://github.com/jedmund/hensei-api">
|
||||||
|
<a
|
||||||
|
href="https://github.com/jedmund/hensei-api"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<div className="Left">
|
||||||
|
<GithubIcon />
|
||||||
|
<h3>jedmund/hensei-api</h3>
|
||||||
|
</div>
|
||||||
|
<ShareIcon className="ShareIcon" />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="Github LinkItem">
|
||||||
|
<Link href="https://github.com/jedmund/hensei-web">
|
||||||
|
<a
|
||||||
|
href="https://github.com/jedmund/hensei-web"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<div className="Left">
|
||||||
|
<GithubIcon />
|
||||||
|
<h3>jedmund/hensei-web</h3>
|
||||||
|
</div>
|
||||||
|
<ShareIcon className="ShareIcon" />
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>{about('about.license.title')}</h2>
|
||||||
|
<p>
|
||||||
|
<Trans i18nKey="about:about.license.license">
|
||||||
|
This app is licensed under{' '}
|
||||||
|
<a
|
||||||
|
href="https://choosealicense.com/licenses/agpl-3.0/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
GNU AGPLv3
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</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
|
||||||
28
components/AccountModal/index.scss
Normal file
28
components/AccountModal/index.scss
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
.Account.DialogContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
width: $unit * 64;
|
||||||
|
overflow-y: hidden;
|
||||||
|
|
||||||
|
.Fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
padding: 0 $unit-4x;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
gap: $unit-4x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogDescription {
|
||||||
|
font-size: $font-regular;
|
||||||
|
line-height: 1.24;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,37 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { getCookie, setCookie } from 'cookies-next'
|
import { getCookie, setCookie } from 'cookies-next'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/router'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslation } from 'next-i18next'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
|
|
||||||
import { Dialog } from '~components/common/Dialog'
|
import {
|
||||||
import DialogHeader from '~components/common/DialogHeader'
|
Dialog,
|
||||||
import DialogFooter from '~components/common/DialogFooter'
|
DialogClose,
|
||||||
import DialogContent from '~components/common/DialogContent'
|
DialogTitle,
|
||||||
import Button from '~components/common/Button'
|
DialogTrigger,
|
||||||
import SelectItem from '~components/common/SelectItem'
|
} from '~components/Dialog'
|
||||||
import SelectTableField from '~components/common/SelectTableField'
|
import DialogContent from '~components/DialogContent'
|
||||||
|
import Button from '~components/Button'
|
||||||
|
import SelectItem from '~components/SelectItem'
|
||||||
|
import PictureSelectItem from '~components/PictureSelectItem'
|
||||||
|
import SelectTableField from '~components/SelectTableField'
|
||||||
|
// import * as Switch from '@radix-ui/react-switch'
|
||||||
|
|
||||||
import api from '~utils/api'
|
import api from '~utils/api'
|
||||||
import changeLanguage from 'utils/changeLanguage'
|
import changeLanguage from 'utils/changeLanguage'
|
||||||
import { accountState } from '~utils/accountState'
|
import { accountState } from '~utils/accountState'
|
||||||
import { pictureData } from '~utils/pictureData'
|
import { pictureData } from '~utils/pictureData'
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
import CrossIcon from '~public/icons/Cross.svg'
|
||||||
import SwitchTableField from '~components/common/SwitchTableField'
|
import './index.scss'
|
||||||
|
|
||||||
|
type StateVariables = {
|
||||||
|
[key: string]: boolean
|
||||||
|
picture: boolean
|
||||||
|
gender: boolean
|
||||||
|
language: boolean
|
||||||
|
theme: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|
@ -30,26 +41,40 @@ interface Props {
|
||||||
language?: string
|
language?: string
|
||||||
theme?: string
|
theme?: string
|
||||||
private?: boolean
|
private?: boolean
|
||||||
role?: number
|
|
||||||
bahamutMode?: boolean
|
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
function AccountModal(props: Props, forwardedRef) {
|
function AccountModal(props: Props, forwardedRef) {
|
||||||
// Localization
|
// Localization
|
||||||
const t = useTranslations('common')
|
const { t } = useTranslation('common')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
// In App Router, locale is handled via cookies
|
const locale =
|
||||||
const currentLocale = getCookie('NEXT_LOCALE') as string || 'en'
|
router.locale && ['en', 'ja'].includes(router.locale)
|
||||||
const locale = ['en', 'ja'].includes(currentLocale) ? currentLocale : 'en'
|
? router.locale
|
||||||
|
: 'en'
|
||||||
|
|
||||||
// useEffect only runs on the client, so now we can safely show the UI
|
// useEffect only runs on the client, so now we can safely show the UI
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
const { theme: appTheme, setTheme: setAppTheme } = useTheme()
|
const { theme: appTheme, setTheme: setAppTheme } = useTheme()
|
||||||
|
|
||||||
|
// Cookies
|
||||||
|
const accountCookie = getCookie('account')
|
||||||
|
const userCookie = getCookie('user')
|
||||||
|
|
||||||
|
const cookieData = {
|
||||||
|
account: accountCookie ? JSON.parse(accountCookie as string) : undefined,
|
||||||
|
user: userCookie ? JSON.parse(userCookie as string) : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [selectOpenState, setSelectOpenState] = useState<StateVariables>({
|
||||||
|
picture: false,
|
||||||
|
gender: false,
|
||||||
|
language: false,
|
||||||
|
theme: false,
|
||||||
|
})
|
||||||
|
|
||||||
// Values
|
// Values
|
||||||
const [username, setUsername] = useState(props.username || '')
|
const [username, setUsername] = useState(props.username || '')
|
||||||
|
|
@ -57,7 +82,7 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
const [language, setLanguage] = useState(props.language || '')
|
const [language, setLanguage] = useState(props.language || '')
|
||||||
const [gender, setGender] = useState(props.gender || 0)
|
const [gender, setGender] = useState(props.gender || 0)
|
||||||
const [theme, setTheme] = useState(props.theme || 'system')
|
const [theme, setTheme] = useState(props.theme || 'system')
|
||||||
const [bahamutMode, setBahamutMode] = useState(props.bahamutMode || false)
|
// const [privateProfile, setPrivateProfile] = useState(false)
|
||||||
|
|
||||||
// Setup
|
// Setup
|
||||||
const [pictureOpen, setPictureOpen] = useState(false)
|
const [pictureOpen, setPictureOpen] = useState(false)
|
||||||
|
|
@ -123,6 +148,7 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
language: language,
|
language: language,
|
||||||
gender: gender,
|
gender: gender,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
|
// private: privateProfile,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +166,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
gender: user.gender,
|
gender: user.gender,
|
||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
bahamut: bahamutMode,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresAt = new Date()
|
const expiresAt = new Date()
|
||||||
|
|
@ -151,7 +176,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
granblueId: '',
|
granblueId: '',
|
||||||
role: user.role,
|
|
||||||
avatar: {
|
avatar: {
|
||||||
picture: user.avatar.picture,
|
picture: user.avatar.picture,
|
||||||
element: user.avatar.element,
|
element: user.avatar.element,
|
||||||
|
|
@ -159,13 +183,11 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
language: user.language,
|
language: user.language,
|
||||||
theme: user.theme,
|
theme: user.theme,
|
||||||
gender: user.gender,
|
gender: user.gender,
|
||||||
bahamut: bahamutMode,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
if (props.onOpenChange) props.onOpenChange(false)
|
if (props.onOpenChange) props.onOpenChange(false)
|
||||||
changeLanguage(router, user.language)
|
changeLanguage(router, user.language)
|
||||||
if (props.bahamutMode != bahamutMode) router.refresh()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -175,20 +197,17 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
.sort((a, b) => (a.name.en > b.name.en ? 1 : -1))
|
.sort((a, b) => (a.name.en > b.name.en ? 1 : -1))
|
||||||
.map((item, i) => {
|
.map((item, i) => {
|
||||||
return (
|
return (
|
||||||
<SelectItem
|
<PictureSelectItem
|
||||||
key={`picture-${i}`}
|
key={`picture-${i}`}
|
||||||
element={item.element}
|
element={item.element}
|
||||||
icon={{
|
src={[
|
||||||
alt: item.name[locale],
|
`/profile/${item.filename}.png`,
|
||||||
src: [
|
`/profile/${item.filename}@2x.png 2x`,
|
||||||
`/profile/${item.filename}.png`,
|
]}
|
||||||
`/profile/${item.filename}@2x.png 2x`,
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
value={item.filename}
|
value={item.filename}
|
||||||
>
|
>
|
||||||
{item.name[locale]}
|
{item.name[locale]}
|
||||||
</SelectItem>
|
</PictureSelectItem>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -196,17 +215,15 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
<SelectTableField
|
<SelectTableField
|
||||||
name="picture"
|
name="picture"
|
||||||
description={t('modals.settings.descriptions.picture')}
|
description={t('modals.settings.descriptions.picture')}
|
||||||
className="image"
|
className="Image"
|
||||||
label={t('modals.settings.labels.picture')}
|
label={t('modals.settings.labels.picture')}
|
||||||
image={{
|
|
||||||
className: pictureData.find((i) => i.filename === picture)?.element,
|
|
||||||
src: [`/profile/${picture}.png`, `/profile/${picture}@2x.png 2x`],
|
|
||||||
alt: pictureData.find((i) => i.filename === picture)?.name[locale],
|
|
||||||
}}
|
|
||||||
open={pictureOpen}
|
open={pictureOpen}
|
||||||
onOpenChange={() => openSelect('picture')}
|
onOpenChange={() => openSelect('picture')}
|
||||||
onChange={handlePictureChange}
|
onChange={handlePictureChange}
|
||||||
onClose={() => setPictureOpen(false)}
|
onClose={() => setPictureOpen(false)}
|
||||||
|
imageAlt={t('modals.settings.labels.image_alt')}
|
||||||
|
imageClass={pictureData.find((i) => i.filename === picture)?.element}
|
||||||
|
imageSrc={[`/profile/${picture}.png`, `/profile/${picture}@2x.png 2x`]}
|
||||||
value={picture}
|
value={picture}
|
||||||
>
|
>
|
||||||
{pictureOptions}
|
{pictureOptions}
|
||||||
|
|
@ -274,15 +291,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
</SelectTableField>
|
</SelectTableField>
|
||||||
)
|
)
|
||||||
|
|
||||||
const adminField = () => (
|
|
||||||
<SwitchTableField
|
|
||||||
name="admin"
|
|
||||||
label={t('modals.settings.labels.admin')}
|
|
||||||
value={props.bahamutMode}
|
|
||||||
onValueChange={(value: boolean) => setBahamutMode(value)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -295,35 +303,38 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||||
<Dialog open={open} onOpenChange={openChange}>
|
<Dialog open={open} onOpenChange={openChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="Account"
|
className="Account"
|
||||||
headerRef={headerRef}
|
headerref={headerRef}
|
||||||
footerRef={footerRef}
|
footerref={footerRef}
|
||||||
onOpenAutoFocus={(event: Event) => {}}
|
onOpenAutoFocus={(event: Event) => {}}
|
||||||
onEscapeKeyDown={onEscapeKeyDown}
|
onEscapeKeyDown={onEscapeKeyDown}
|
||||||
>
|
>
|
||||||
<DialogHeader
|
<div className="DialogHeader" ref={headerRef}>
|
||||||
title={`@${username}`}
|
<div className="DialogTop">
|
||||||
subtitle={t('modals.settings.title')}
|
<DialogTitle className="SubTitle">
|
||||||
/>
|
{t('modals.settings.title')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogTitle className="DialogTitle">@{username}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogClose className="DialogClose" asChild>
|
||||||
|
<span>
|
||||||
|
<CrossIcon />
|
||||||
|
</span>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={update}>
|
<form onSubmit={update}>
|
||||||
<div className={styles.fields}>
|
<div className="Fields">
|
||||||
{pictureField()}
|
{pictureField()}
|
||||||
{genderField()}
|
{genderField()}
|
||||||
{languageField()}
|
{languageField()}
|
||||||
{themeField()}
|
{themeField()}
|
||||||
{props.role === 9 && adminField()}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="DialogFooter" ref={footerRef}>
|
||||||
<DialogFooter
|
<Button
|
||||||
ref={footerRef}
|
contained={true}
|
||||||
rightElements={[
|
text={t('modals.settings.buttons.confirm')}
|
||||||
<Button
|
/>
|
||||||
bound={true}
|
</div>
|
||||||
key="confirm"
|
|
||||||
text={t('modals.settings.buttons.confirm')}
|
|
||||||
/>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
@ -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,4 +1,4 @@
|
||||||
.wrapper {
|
.AlertWrapper {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -7,20 +7,10 @@
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 12;
|
z-index: 31;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay {
|
.Alert {
|
||||||
isolation: isolate;
|
|
||||||
position: fixed;
|
|
||||||
z-index: 9;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
animation: $duration-modal-open cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none
|
animation: $duration-modal-open cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none
|
||||||
running openModalDesktop;
|
running openModalDesktop;
|
||||||
background: var(--dialog-bg);
|
background: var(--dialog-bg);
|
||||||
|
|
@ -29,23 +19,17 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
min-width: 20vw;
|
min-width: 20vw;
|
||||||
max-width: 40vw;
|
max-width: 30vw;
|
||||||
padding: $unit * 4;
|
padding: $unit * 4;
|
||||||
|
|
||||||
@include breakpoint(tablet) {
|
|
||||||
max-width: inherit;
|
|
||||||
max-width: 60vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
@include breakpoint(phone) {
|
||||||
max-width: inherit;
|
max-width: inherit;
|
||||||
width: 70vw;
|
width: 60vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
font-size: $font-regular;
|
font-size: $font-regular;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: pre-line;
|
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
font-weight: $bold;
|
font-weight: $bold;
|
||||||
|
|
@ -56,27 +40,5 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
|
|
||||||
@include breakpoint(phone) {
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
align-self: center;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes openModalDesktop {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
// opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
import './index.scss'
|
||||||
import Button from '~components/common/Button'
|
import Button from '~components/Button'
|
||||||
import Overlay from '~components/common/Overlay'
|
import Overlay from '~components/Overlay'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -12,7 +12,6 @@ interface Props {
|
||||||
message: string | React.ReactNode
|
message: string | React.ReactNode
|
||||||
primaryAction?: () => void
|
primaryAction?: () => void
|
||||||
primaryActionText?: string
|
primaryActionText?: string
|
||||||
primaryActionClassName?: string
|
|
||||||
cancelAction: () => void
|
cancelAction: () => void
|
||||||
cancelActionText: string
|
cancelActionText: string
|
||||||
}
|
}
|
||||||
|
|
@ -21,44 +20,40 @@ const Alert = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<AlertDialog.Root open={props.open}>
|
<AlertDialog.Root open={props.open}>
|
||||||
<AlertDialog.Portal>
|
<AlertDialog.Portal>
|
||||||
<Overlay
|
<AlertDialog.Overlay className="Overlay" onClick={props.cancelAction} />
|
||||||
className="alert"
|
<div className="AlertWrapper">
|
||||||
open={props.open}
|
<AlertDialog.Content className="Alert">
|
||||||
visible={true}
|
{props.title ? (
|
||||||
onClick={props.cancelAction}
|
|
||||||
/>
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<AlertDialog.Content
|
|
||||||
className={styles.alert}
|
|
||||||
onEscapeKeyDown={props.cancelAction}
|
|
||||||
>
|
|
||||||
{props.title && (
|
|
||||||
<AlertDialog.Title>{props.title}</AlertDialog.Title>
|
<AlertDialog.Title>{props.title}</AlertDialog.Title>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
)}
|
)}
|
||||||
<AlertDialog.Description className={styles.description}>
|
<AlertDialog.Description className="description">
|
||||||
{props.message}
|
{props.message}
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
<div className={styles.buttons}>
|
<div className="buttons">
|
||||||
<AlertDialog.Cancel asChild>
|
<AlertDialog.Cancel asChild>
|
||||||
<Button
|
<Button
|
||||||
bound={true}
|
contained={true}
|
||||||
onClick={props.cancelAction}
|
onClick={props.cancelAction}
|
||||||
text={props.cancelActionText}
|
text={props.cancelActionText}
|
||||||
/>
|
/>
|
||||||
</AlertDialog.Cancel>
|
</AlertDialog.Cancel>
|
||||||
{props.primaryAction && (
|
{props.primaryAction ? (
|
||||||
<AlertDialog.Action asChild>
|
<AlertDialog.Action asChild>
|
||||||
<Button
|
<Button
|
||||||
className={props.primaryActionClassName}
|
contained={true}
|
||||||
bound={true}
|
|
||||||
onClick={props.primaryAction}
|
onClick={props.primaryAction}
|
||||||
text={props.primaryActionText}
|
text={props.primaryActionText}
|
||||||
/>
|
/>
|
||||||
</AlertDialog.Action>
|
</AlertDialog.Action>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AlertDialog.Content>
|
</AlertDialog.Content>
|
||||||
</div>
|
</div>
|
||||||
|
<Overlay open={props.open} visible={true} />
|
||||||
</AlertDialog.Portal>
|
</AlertDialog.Portal>
|
||||||
</AlertDialog.Root>
|
</AlertDialog.Root>
|
||||||
)
|
)
|
||||||
37
components/AwakeningSelect/index.scss
Normal file
37
components/AwakeningSelect/index.scss
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
.AwakeningSelect .AwakeningSet {
|
||||||
|
.errors {
|
||||||
|
color: $error;
|
||||||
|
display: none;
|
||||||
|
padding: $unit 0;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.SelectTrigger {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Label {
|
||||||
|
display: none;
|
||||||
|
flex-grow: 0;
|
||||||
|
|
||||||
|
&.Visible {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Input {
|
||||||
|
min-width: $unit * 12;
|
||||||
|
width: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
components/AwakeningSelect/index.tsx
Normal file
97
components/AwakeningSelect/index.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import cloneDeep from 'lodash.clonedeep'
|
||||||
|
|
||||||
|
import SelectWithInput from '~components/SelectWithInput'
|
||||||
|
import { weaponAwakening, characterAwakening } from '~data/awakening'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
object: 'character' | 'weapon'
|
||||||
|
type?: number
|
||||||
|
level?: number
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
sendValidity: (isValid: boolean) => void
|
||||||
|
sendValues: (type: number, level: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AwakeningSelect = (props: Props) => {
|
||||||
|
// Data states
|
||||||
|
const [awakeningType, setAwakeningType] = useState(
|
||||||
|
props.object === 'weapon' ? 0 : 1
|
||||||
|
)
|
||||||
|
const [awakeningLevel, setAwakeningLevel] = useState(1)
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const chooseDataset = () => {
|
||||||
|
let list: ItemSkill[] = []
|
||||||
|
|
||||||
|
switch (props.object) {
|
||||||
|
case 'character':
|
||||||
|
list = characterAwakening
|
||||||
|
break
|
||||||
|
case 'weapon':
|
||||||
|
// WARNING: Clonedeep is masking a deeper error
|
||||||
|
// which is running this method every time this component is rerendered
|
||||||
|
// causing multiple "No awakening" items to be added
|
||||||
|
const awakening = cloneDeep(weaponAwakening)
|
||||||
|
awakening.unshift({
|
||||||
|
id: 0,
|
||||||
|
name: {
|
||||||
|
en: 'No awakening',
|
||||||
|
ja: '覚醒なし',
|
||||||
|
},
|
||||||
|
granblue_id: '',
|
||||||
|
slug: 'no-awakening',
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 0,
|
||||||
|
fractional: false,
|
||||||
|
})
|
||||||
|
list = awakening
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default awakening and level based on object type
|
||||||
|
useEffect(() => {
|
||||||
|
const defaultAwakening = props.object === 'weapon' ? 0 : 1
|
||||||
|
const type = props.type != undefined ? props.type : defaultAwakening
|
||||||
|
|
||||||
|
setAwakeningType(type)
|
||||||
|
setAwakeningLevel(props.level ? props.level : 1)
|
||||||
|
}, [props.object, props.type, props.level])
|
||||||
|
|
||||||
|
// Send validity of form when awakening level changes
|
||||||
|
useEffect(() => {
|
||||||
|
props.sendValidity(awakeningLevel > 0)
|
||||||
|
}, [props.sendValidity, awakeningLevel])
|
||||||
|
|
||||||
|
// Classes
|
||||||
|
function changeOpen(open: boolean) {
|
||||||
|
if (props.onOpenChange) props.onOpenChange(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleValueChange(type: number, level: number) {
|
||||||
|
setAwakeningType(type)
|
||||||
|
setAwakeningLevel(level)
|
||||||
|
props.sendValues(type, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="Awakening">
|
||||||
|
<SelectWithInput
|
||||||
|
object={`${props.object}_awakening`}
|
||||||
|
dataSet={chooseDataset()}
|
||||||
|
selectValue={awakeningType}
|
||||||
|
inputValue={awakeningLevel}
|
||||||
|
onOpenChange={changeOpen}
|
||||||
|
sendValidity={props.sendValidity}
|
||||||
|
sendValues={handleValueChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AwakeningSelect
|
||||||
47
components/AxSelect/index.scss
Normal file
47
components/AxSelect/index.scss
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
.AXSelect {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
.AXSet {
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errors {
|
||||||
|
color: $error;
|
||||||
|
display: none;
|
||||||
|
padding: $unit 0;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
.SelectTrigger {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
border: none;
|
||||||
|
border-radius: $input-corner;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: none;
|
||||||
|
text-align: right;
|
||||||
|
min-width: $unit-14x;
|
||||||
|
width: 100px;
|
||||||
|
|
||||||
|
&.Visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
'use client'
|
import React, { ForwardedRef, useEffect, useState } from 'react'
|
||||||
import React, { useEffect, useState } from 'react'
|
import { useRouter } from 'next/router'
|
||||||
import { getCookie } from 'cookies-next'
|
import { useTranslation } from 'next-i18next'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
|
|
||||||
import Input from '~components/common/Input'
|
import Select from '~components/Select'
|
||||||
import Select from '~components/common/Select'
|
import SelectItem from '~components/SelectItem'
|
||||||
import SelectItem from '~components/common/SelectItem'
|
|
||||||
|
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
import ax from '~data/ax'
|
import ax from '~data/ax'
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface ErrorMap {
|
interface ErrorMap {
|
||||||
[index: string]: string
|
[index: string]: string
|
||||||
|
|
@ -33,8 +31,10 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const AXSelect = (props: Props) => {
|
const AXSelect = (props: Props) => {
|
||||||
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
|
const router = useRouter()
|
||||||
const t = useTranslations('common')
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
const [openAX1, setOpenAX1] = useState(false)
|
const [openAX1, setOpenAX1] = useState(false)
|
||||||
const [openAX2, setOpenAX2] = useState(false)
|
const [openAX2, setOpenAX2] = useState(false)
|
||||||
|
|
@ -45,19 +45,14 @@ const AXSelect = (props: Props) => {
|
||||||
axValue2: '',
|
axValue2: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const inputClasses = classNames({
|
|
||||||
fullHeight: true,
|
|
||||||
range: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const primaryErrorClasses = classNames({
|
const primaryErrorClasses = classNames({
|
||||||
[styles.errors]: true,
|
errors: true,
|
||||||
[styles.visible]: errors.axValue1.length > 0,
|
visible: errors.axValue1.length > 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const secondaryErrorClasses = classNames({
|
const secondaryErrorClasses = classNames({
|
||||||
[styles.errors]: true,
|
errors: true,
|
||||||
[styles.visible]: errors.axValue2.length > 0,
|
visible: errors.axValue2.length > 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
|
|
@ -69,12 +64,8 @@ const AXSelect = (props: Props) => {
|
||||||
// States
|
// States
|
||||||
const [primaryAxModifier, setPrimaryAxModifier] = useState(-1)
|
const [primaryAxModifier, setPrimaryAxModifier] = useState(-1)
|
||||||
const [secondaryAxModifier, setSecondaryAxModifier] = useState(-1)
|
const [secondaryAxModifier, setSecondaryAxModifier] = useState(-1)
|
||||||
const [primaryAxValue, setPrimaryAxValue] = useState(
|
const [primaryAxValue, setPrimaryAxValue] = useState(0.0)
|
||||||
props.currentSkills ? props.currentSkills[0].strength : 0.0
|
const [secondaryAxValue, setSecondaryAxValue] = useState(0.0)
|
||||||
)
|
|
||||||
const [secondaryAxValue, setSecondaryAxValue] = useState(
|
|
||||||
props.currentSkills ? props.currentSkills[1].strength : 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setupAx1()
|
setupAx1()
|
||||||
|
|
@ -148,11 +139,8 @@ const AXSelect = (props: Props) => {
|
||||||
|
|
||||||
// Classes
|
// Classes
|
||||||
const secondarySetClasses = classNames({
|
const secondarySetClasses = classNames({
|
||||||
[styles.set]: true,
|
AXSet: true,
|
||||||
[styles.hidden]:
|
hidden: primaryAxModifier < 0,
|
||||||
primaryAxModifier < 0 ||
|
|
||||||
primaryAxModifier === 18 ||
|
|
||||||
primaryAxModifier === 19,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function setupAx1() {
|
function setupAx1() {
|
||||||
|
|
@ -276,17 +264,14 @@ const AXSelect = (props: Props) => {
|
||||||
secondaryAxModifierSelect.current &&
|
secondaryAxModifierSelect.current &&
|
||||||
secondaryAxValueInput.current
|
secondaryAxValueInput.current
|
||||||
) {
|
) {
|
||||||
setupInput(
|
setupInput(ax[props.axType - 1][value], primaryAxValueInput.current)
|
||||||
ax[props.axType - 1].find((ax) => ax.id === value),
|
|
||||||
primaryAxValueInput.current
|
|
||||||
)
|
|
||||||
|
|
||||||
setPrimaryAxValue(0)
|
setPrimaryAxValue(0)
|
||||||
|
primaryAxValueInput.current.value = ''
|
||||||
|
|
||||||
// Reset the secondary AX modifier, reset the AX value and hide the input
|
// Reset the secondary AX modifier, reset the AX value and hide the input
|
||||||
setSecondaryAxModifier(-1)
|
setSecondaryAxModifier(-1)
|
||||||
setSecondaryAxValue(0)
|
setSecondaryAxValue(0)
|
||||||
// secondaryAxValueInput.current.className = 'Input Contained'
|
secondaryAxValueInput.current.className = 'Input Contained'
|
||||||
secondaryAxValueInput.current.value = ''
|
secondaryAxValueInput.current.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +296,7 @@ const AXSelect = (props: Props) => {
|
||||||
const value = parseFloat(event.target.value)
|
const value = parseFloat(event.target.value)
|
||||||
let newErrors = { ...errors }
|
let newErrors = { ...errors }
|
||||||
|
|
||||||
if (primaryAxValueInput.current === event.target) {
|
if (primaryAxValueInput.current == event.target) {
|
||||||
if (handlePrimaryErrors(value)) setPrimaryAxValue(value)
|
if (handlePrimaryErrors(value)) setPrimaryAxValue(value)
|
||||||
} else {
|
} else {
|
||||||
if (handleSecondaryErrors(value)) setSecondaryAxValue(value)
|
if (handleSecondaryErrors(value)) setSecondaryAxValue(value)
|
||||||
|
|
@ -319,18 +304,16 @@ const AXSelect = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePrimaryErrors(value: number) {
|
function handlePrimaryErrors(value: number) {
|
||||||
const primaryAxSkill = ax[props.axType - 1].find(
|
const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
|
||||||
(ax) => ax.id === primaryAxModifier
|
|
||||||
)
|
|
||||||
let newErrors = { ...errors }
|
let newErrors = { ...errors }
|
||||||
|
|
||||||
if (primaryAxSkill && value < primaryAxSkill.minValue) {
|
if (value < primaryAxSkill.minValue) {
|
||||||
newErrors.axValue1 = t('ax.errors.value_too_low', {
|
newErrors.axValue1 = t('ax.errors.value_too_low', {
|
||||||
name: primaryAxSkill.name[locale],
|
name: primaryAxSkill.name[locale],
|
||||||
minValue: primaryAxSkill.minValue,
|
minValue: primaryAxSkill.minValue,
|
||||||
suffix: primaryAxSkill.suffix ? primaryAxSkill.suffix : '',
|
suffix: primaryAxSkill.suffix ? primaryAxSkill.suffix : '',
|
||||||
})
|
})
|
||||||
} else if (primaryAxSkill && value > primaryAxSkill.maxValue) {
|
} else if (value > primaryAxSkill.maxValue) {
|
||||||
newErrors.axValue1 = t('ax.errors.value_too_high', {
|
newErrors.axValue1 = t('ax.errors.value_too_high', {
|
||||||
name: primaryAxSkill.name[locale],
|
name: primaryAxSkill.name[locale],
|
||||||
maxValue: primaryAxSkill.maxValue,
|
maxValue: primaryAxSkill.maxValue,
|
||||||
|
|
@ -338,7 +321,7 @@ const AXSelect = (props: Props) => {
|
||||||
})
|
})
|
||||||
} else if (!value || value <= 0) {
|
} else if (!value || value <= 0) {
|
||||||
newErrors.axValue1 = t('ax.errors.value_empty', {
|
newErrors.axValue1 = t('ax.errors.value_empty', {
|
||||||
name: primaryAxSkill?.name[locale] || '',
|
name: primaryAxSkill.name[locale],
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
newErrors.axValue1 = ''
|
newErrors.axValue1 = ''
|
||||||
|
|
@ -391,11 +374,10 @@ const AXSelect = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupInput(ax: ItemSkill | undefined, element: HTMLInputElement) {
|
function setupInput(ax: ItemSkill | undefined, element: HTMLInputElement) {
|
||||||
console.log(ax)
|
|
||||||
if (ax) {
|
if (ax) {
|
||||||
const rangeString = `${ax.minValue}~${ax.maxValue}${ax.suffix || ''}`
|
const rangeString = `${ax.minValue}~${ax.maxValue}${ax.suffix || ''}`
|
||||||
|
|
||||||
// element.className = 'Input Bound Visible'
|
element.className = 'Input Bound Visible'
|
||||||
element.disabled = false
|
element.disabled = false
|
||||||
element.placeholder = rangeString
|
element.placeholder = rangeString
|
||||||
element.min = `${ax.minValue}`
|
element.min = `${ax.minValue}`
|
||||||
|
|
@ -404,12 +386,12 @@ const AXSelect = (props: Props) => {
|
||||||
} else {
|
} else {
|
||||||
if (primaryAxValueInput.current && secondaryAxValueInput.current) {
|
if (primaryAxValueInput.current && secondaryAxValueInput.current) {
|
||||||
if (primaryAxValueInput.current == element) {
|
if (primaryAxValueInput.current == element) {
|
||||||
// primaryAxValueInput.current.className = 'Input Contained'
|
primaryAxValueInput.current.className = 'Input Contained'
|
||||||
primaryAxValueInput.current.disabled = true
|
primaryAxValueInput.current.disabled = true
|
||||||
primaryAxValueInput.current.placeholder = ''
|
primaryAxValueInput.current.placeholder = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// secondaryAxValueInput.current.className = 'Input Contained'
|
secondaryAxValueInput.current.className = 'Input Contained'
|
||||||
secondaryAxValueInput.current.disabled = true
|
secondaryAxValueInput.current.disabled = true
|
||||||
secondaryAxValueInput.current.placeholder = ''
|
secondaryAxValueInput.current.placeholder = ''
|
||||||
}
|
}
|
||||||
|
|
@ -417,33 +399,28 @@ const AXSelect = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.ax}>
|
<div className="AXSelect">
|
||||||
<div className={styles.set}>
|
<div className="AXSet">
|
||||||
<div className={styles.fields}>
|
<div className="fields">
|
||||||
<Select
|
<Select
|
||||||
key="ax1"
|
key="ax1"
|
||||||
value={`${primaryAxModifier}`}
|
value={`${primaryAxModifier}`}
|
||||||
open={openAX1}
|
open={openAX1}
|
||||||
trigger={{
|
|
||||||
bound: true,
|
|
||||||
className: 'grow',
|
|
||||||
}}
|
|
||||||
onClose={() => onClose(1)}
|
onClose={() => onClose(1)}
|
||||||
onOpenChange={() => openSelect(1)}
|
onOpenChange={() => openSelect(1)}
|
||||||
onValueChange={handleAX1SelectChange}
|
onValueChange={handleAX1SelectChange}
|
||||||
|
triggerClass="modal"
|
||||||
overlayVisible={false}
|
overlayVisible={false}
|
||||||
>
|
>
|
||||||
{generateOptions(0)}
|
{generateOptions(0)}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Input
|
<input
|
||||||
className={inputClasses}
|
defaultValue={
|
||||||
wrapperClassName="fullHeight"
|
props.currentSkills && props.currentSkills[0]
|
||||||
fieldsetClassName={classNames({
|
? props.currentSkills[0].strength
|
||||||
hidden: primaryAxModifier < 0,
|
: 0
|
||||||
})}
|
}
|
||||||
bound={true}
|
|
||||||
value={primaryAxValue}
|
|
||||||
type="number"
|
type="number"
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
ref={primaryAxValueInput}
|
ref={primaryAxValueInput}
|
||||||
|
|
@ -453,31 +430,26 @@ const AXSelect = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={secondarySetClasses}>
|
<div className={secondarySetClasses}>
|
||||||
<div className={styles.fields}>
|
<div className="fields">
|
||||||
<Select
|
<Select
|
||||||
key="ax2"
|
key="ax2"
|
||||||
value={`${secondaryAxModifier}`}
|
value={`${secondaryAxModifier}`}
|
||||||
open={openAX2}
|
open={openAX2}
|
||||||
trigger={{
|
|
||||||
bound: true,
|
|
||||||
className: 'grow',
|
|
||||||
}}
|
|
||||||
onClose={() => onClose(2)}
|
onClose={() => onClose(2)}
|
||||||
onOpenChange={() => openSelect(2)}
|
onOpenChange={() => openSelect(2)}
|
||||||
onValueChange={handleAX2SelectChange}
|
onValueChange={handleAX2SelectChange}
|
||||||
|
triggerClass="modal"
|
||||||
ref={secondaryAxModifierSelect}
|
ref={secondaryAxModifierSelect}
|
||||||
overlayVisible={false}
|
overlayVisible={false}
|
||||||
>
|
>
|
||||||
{generateOptions(1)}
|
{generateOptions(1)}
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<input
|
||||||
className={inputClasses}
|
defaultValue={
|
||||||
wrapperClassName="fullHeight"
|
props.currentSkills && props.currentSkills[1]
|
||||||
fieldsetClassName={classNames({
|
? props.currentSkills[1].strength
|
||||||
hidden: secondaryAxModifier < 0,
|
: 0
|
||||||
})}
|
}
|
||||||
bound={true}
|
|
||||||
value={secondaryAxValue}
|
|
||||||
type="number"
|
type="number"
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
ref={secondaryAxValueInput}
|
ref={secondaryAxValueInput}
|
||||||
318
components/Button/index.scss
Normal file
318
components/Button/index.scss
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
.Button {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--button-bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: $input-corner;
|
||||||
|
color: var(--button-text);
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: $font-button;
|
||||||
|
font-weight: $normal;
|
||||||
|
gap: 6px;
|
||||||
|
transition: 0.18s opacity ease-in-out;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.Blended:hover,
|
||||||
|
&.Blended.Active {
|
||||||
|
background: var(--button-bg-hover);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--button-text-hover);
|
||||||
|
|
||||||
|
.Accessory svg {
|
||||||
|
fill: var(--button-text-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Accessory svg.stroke {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--button-text-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Blended {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.IconButton.medium {
|
||||||
|
height: inherit;
|
||||||
|
padding: $unit-half;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Text {
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $bold;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Contained {
|
||||||
|
background: var(--button-contained-bg);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-contained-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Save:hover .Accessory svg {
|
||||||
|
fill: #ff4d4d;
|
||||||
|
stroke: #ff4d4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Save {
|
||||||
|
color: #ff4d4d;
|
||||||
|
|
||||||
|
&.Active .Accessory svg {
|
||||||
|
fill: #ff4d4d;
|
||||||
|
stroke: #ff4d4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: darken(#ff4d4d, 30);
|
||||||
|
|
||||||
|
.Accessory svg {
|
||||||
|
fill: darken(#ff4d4d, 30);
|
||||||
|
stroke: darken(#ff4d4d, 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Options {
|
||||||
|
box-shadow: 0px 1px 3px rgb(0 0 0 / 14%);
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 8px;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: var(--button-bg-disabled);
|
||||||
|
color: var(--button-text-disabled);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--button-bg-disabled);
|
||||||
|
color: var(--button-text-disabled);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.medium {
|
||||||
|
height: $unit * 5.5;
|
||||||
|
padding: ($unit * 1.5) $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
padding: $unit * 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
&.destructive {
|
||||||
|
background: $error;
|
||||||
|
color: $grey-100;
|
||||||
|
|
||||||
|
.Accessory svg {
|
||||||
|
fill: $grey-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.destructive:hover {
|
||||||
|
background: $error;
|
||||||
|
color: $grey-100;
|
||||||
|
|
||||||
|
.Accessory svg {
|
||||||
|
fill: $grey-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Save {
|
||||||
|
.Accessory svg {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--button-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Saved {
|
||||||
|
color: #ff4d4d;
|
||||||
|
|
||||||
|
.Accessory svg {
|
||||||
|
fill: #ff4d4d;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ff4d4d;
|
||||||
|
|
||||||
|
.Accessory svg {
|
||||||
|
fill: none;
|
||||||
|
stroke: #ff4d4d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.modal:hover {
|
||||||
|
background: $grey-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.modal.destructive {
|
||||||
|
color: $error;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: darken($error, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Accessory {
|
||||||
|
$dimension: $unit-2x;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&.Arrow {
|
||||||
|
margin-top: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: var(--button-text);
|
||||||
|
height: $dimension;
|
||||||
|
width: $dimension;
|
||||||
|
|
||||||
|
&.stroke {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--button-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Add {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.Check {
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.check svg {
|
||||||
|
margin-top: 1px;
|
||||||
|
height: 14px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg &.settings svg {
|
||||||
|
height: 13px;
|
||||||
|
width: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-blue {
|
||||||
|
background: $blue;
|
||||||
|
color: #8b8b8b;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #4b9be5;
|
||||||
|
color: #233e56;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-red {
|
||||||
|
background: #fa4242;
|
||||||
|
color: #860f0f;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e91a1a;
|
||||||
|
color: #4e1717;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: #4e1717;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: #860f0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-disabled {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #bababa;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #bababa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.null {
|
||||||
|
background: $grey-90;
|
||||||
|
color: $grey-55;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-70;
|
||||||
|
color: $grey-15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wind {
|
||||||
|
background: $wind-bg-20;
|
||||||
|
color: $wind-text-10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken($wind-bg-20, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fire {
|
||||||
|
background: $fire-bg-20;
|
||||||
|
color: $fire-text-10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken($fire-bg-20, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.water {
|
||||||
|
background: $water-bg-20;
|
||||||
|
color: $water-text-10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken($water-bg-20, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.earth {
|
||||||
|
background: $earth-bg-20;
|
||||||
|
color: $earth-text-10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken($earth-bg-20, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
background: $dark-bg-10;
|
||||||
|
color: $dark-text-10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken($dark-bg-10, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.light {
|
||||||
|
background: $light-bg-20;
|
||||||
|
color: $light-text-10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: darken($light-bg-20, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Text {
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface Props
|
interface Props
|
||||||
extends React.DetailedHTMLProps<
|
extends React.DetailedHTMLProps<
|
||||||
|
|
@ -14,18 +14,16 @@ interface Props
|
||||||
rightAccessoryClassName?: string
|
rightAccessoryClassName?: string
|
||||||
active?: boolean
|
active?: boolean
|
||||||
blended?: boolean
|
blended?: boolean
|
||||||
bound?: boolean
|
contained?: boolean
|
||||||
floating?: boolean
|
buttonSize?: 'small' | 'medium' | 'large'
|
||||||
size?: 'icon' | 'small' | 'medium' | 'large'
|
|
||||||
text?: string
|
text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
active: false,
|
active: false,
|
||||||
blended: false,
|
blended: false,
|
||||||
bound: false,
|
contained: false,
|
||||||
floating: false,
|
buttonSize: 'medium' as const,
|
||||||
size: 'medium' as const,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
||||||
|
|
@ -36,44 +34,29 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
||||||
rightAccessoryClassName,
|
rightAccessoryClassName,
|
||||||
active,
|
active,
|
||||||
blended,
|
blended,
|
||||||
floating,
|
contained,
|
||||||
bound,
|
buttonSize,
|
||||||
size,
|
|
||||||
text,
|
text,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
forwardedRef
|
forwardedRef
|
||||||
) {
|
) {
|
||||||
const classes = classNames(
|
const classes = classNames(buttonSize, props.className, {
|
||||||
{
|
Button: true,
|
||||||
[styles.button]: true,
|
Active: active,
|
||||||
[styles.active]: active,
|
Blended: blended,
|
||||||
[styles.bound]: bound,
|
Contained: contained,
|
||||||
[styles.blended]: blended,
|
})
|
||||||
[styles.floating]: floating,
|
|
||||||
[styles.icon]: size === 'icon',
|
|
||||||
[styles.small]: size === 'small',
|
|
||||||
[styles.medium]: size === 'medium' || !size,
|
|
||||||
[styles.large]: size === 'large',
|
|
||||||
},
|
|
||||||
props.className?.split(' ').map((className) => styles[className])
|
|
||||||
)
|
|
||||||
|
|
||||||
const leftAccessoryClasses = classNames(
|
const leftAccessoryClasses = classNames(leftAccessoryClassName, {
|
||||||
{
|
Accessory: true,
|
||||||
[styles.accessory]: true,
|
Left: true,
|
||||||
[styles.left]: true,
|
})
|
||||||
},
|
|
||||||
leftAccessoryClassName?.split(' ').map((className) => styles[className])
|
|
||||||
)
|
|
||||||
|
|
||||||
const rightAccessoryClasses = classNames(
|
const rightAccessoryClasses = classNames(rightAccessoryClassName, {
|
||||||
{
|
Accessory: true,
|
||||||
[styles.accessory]: true,
|
Right: true,
|
||||||
[styles.right]: true,
|
})
|
||||||
},
|
|
||||||
rightAccessoryClassName?.split(' ').map((className) => styles[className])
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasLeftAccessory = () => {
|
const hasLeftAccessory = () => {
|
||||||
if (leftAccessoryIcon)
|
if (leftAccessoryIcon)
|
||||||
|
|
@ -86,7 +69,7 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasText = () => {
|
const hasText = () => {
|
||||||
if (text) return <span className={styles.text}>{text}</span>
|
if (text) return <span className="Text">{text}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
.unit {
|
.ChangelogUnit {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
94
components/ChangelogUnit/index.tsx
Normal file
94
components/ChangelogUnit/index.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import api from '~utils/api'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
type: 'character' | 'summon' | 'weapon'
|
||||||
|
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) => {
|
||||||
|
// Router
|
||||||
|
const router = useRouter()
|
||||||
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [item, setItem] = useState<Character | Weapon | Summon>()
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
useEffect(() => {
|
||||||
|
fetch()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
switch (type) {
|
||||||
|
case 'character':
|
||||||
|
const character = await fetchCharacter()
|
||||||
|
setItem(character.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'weapon':
|
||||||
|
const weapon = await fetchWeapon()
|
||||||
|
setItem(weapon.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'summon':
|
||||||
|
const summon = await fetchSummon()
|
||||||
|
setItem(summon.data)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCharacter() {
|
||||||
|
return api.endpoints.characters.getOne({ id: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWeapon() {
|
||||||
|
return api.endpoints.weapons.getOne({ id: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSummon() {
|
||||||
|
return api.endpoints.summons.getOne({ id: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = () => {
|
||||||
|
let src = ''
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'character':
|
||||||
|
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${id}_${image}.jpg`
|
||||||
|
break
|
||||||
|
case 'weapon':
|
||||||
|
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/weapon-grid/${id}.jpg`
|
||||||
|
break
|
||||||
|
case 'summon':
|
||||||
|
src = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/summon-grid/${id}.jpg`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ChangelogUnit" key={id}>
|
||||||
|
<img alt={item ? item.name[locale] : ''} src={imageUrl()} />
|
||||||
|
<h4>{item ? item.name[locale] : ''}</h4>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChangelogUnit.defaultProps = defaultProps
|
||||||
|
|
||||||
|
export default ChangelogUnit
|
||||||
38
components/CharLimitedFieldset/index.scss
Normal file
38
components/CharLimitedFieldset/index.scss
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
.Limited {
|
||||||
|
$offset: 2px;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border-radius: $input-corner;
|
||||||
|
border: $offset solid transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
gap: $unit;
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
padding-right: calc($unit-2x - $offset);
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border: $offset solid $blue;
|
||||||
|
// box-shadow: 0 2px rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Counter {
|
||||||
|
color: $grey-55;
|
||||||
|
font-weight: $bold;
|
||||||
|
line-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: $unit * 1.5 $unit-2x;
|
||||||
|
padding-left: calc($unit-2x - $offset);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
components/CharLimitedFieldset/index.tsx
Normal file
57
components/CharLimitedFieldset/index.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fieldName: string
|
||||||
|
placeholder: string
|
||||||
|
value?: string
|
||||||
|
limit: number
|
||||||
|
error: string
|
||||||
|
onBlur?: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CharLimitedFieldset = React.forwardRef<HTMLInputElement, Props>(
|
||||||
|
function useFieldSet(props, ref) {
|
||||||
|
const fieldType = ['password', 'confirm_password'].includes(props.fieldName)
|
||||||
|
? 'password'
|
||||||
|
: 'text'
|
||||||
|
|
||||||
|
const [currentCount, setCurrentCount] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentCount(
|
||||||
|
props.value ? props.limit - props.value.length : props.limit
|
||||||
|
)
|
||||||
|
}, [props.limit, props.value])
|
||||||
|
|
||||||
|
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
setCurrentCount(props.limit - event.currentTarget.value.length)
|
||||||
|
if (props.onChange) props.onChange(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="Fieldset">
|
||||||
|
<div className="Limited">
|
||||||
|
<input
|
||||||
|
autoComplete="off"
|
||||||
|
className="Input"
|
||||||
|
type={fieldType}
|
||||||
|
name={props.fieldName}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
defaultValue={props.value || ''}
|
||||||
|
onBlur={props.onBlur}
|
||||||
|
onChange={onChange}
|
||||||
|
maxLength={props.limit}
|
||||||
|
ref={ref}
|
||||||
|
formNoValidate
|
||||||
|
/>
|
||||||
|
<span className="Counter">{currentCount}</span>
|
||||||
|
</div>
|
||||||
|
{props.error.length > 0 && <p className="InputError">{props.error}</p>}
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default CharLimitedFieldset
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
import { useRouter } from 'next/router'
|
||||||
import { getCookie } from 'cookies-next'
|
import { Trans, useTranslation } from 'next-i18next'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
|
|
||||||
import { Dialog } from '~components/common/Dialog'
|
import { Dialog } from '~components/Dialog'
|
||||||
import DialogContent from '~components/common/DialogContent'
|
import DialogContent from '~components/DialogContent'
|
||||||
import DialogFooter from '~components/common/DialogFooter'
|
import Button from '~components/Button'
|
||||||
import Button from '~components/common/Button'
|
import Overlay from '~components/Overlay'
|
||||||
import Overlay from '~components/common/Overlay'
|
|
||||||
|
|
||||||
import { appState } from '~utils/appState'
|
import { appState } from '~utils/appState'
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|
@ -27,12 +23,9 @@ interface Props {
|
||||||
const CharacterConflictModal = (props: Props) => {
|
const CharacterConflictModal = (props: Props) => {
|
||||||
// Localization
|
// Localization
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const { t } = useTranslation('common')
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const t = useTranslations('common')
|
|
||||||
const routerLocale = getCookie('NEXT_LOCALE')
|
|
||||||
const locale =
|
const locale =
|
||||||
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en'
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
|
||||||
// States
|
// States
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
@ -66,7 +59,7 @@ const CharacterConflictModal = (props: Props) => {
|
||||||
suffix = `${suffix}_0${element}`
|
suffix = `${suffix}_0${element}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-square/${character?.granblue_id}_${suffix}.jpg`
|
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-square/${character?.granblue_id}_${suffix}.jpg`
|
||||||
}
|
}
|
||||||
|
|
||||||
function openChange(open: boolean) {
|
function openChange(open: boolean) {
|
||||||
|
|
@ -82,21 +75,19 @@ const CharacterConflictModal = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={openChange}>
|
<Dialog open={open} onOpenChange={openChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="conflict"
|
className="Conflict"
|
||||||
footerRef={footerRef}
|
footerref={footerRef}
|
||||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
onEscapeKeyDown={close}
|
onEscapeKeyDown={close}
|
||||||
>
|
>
|
||||||
<div className={styles.content}>
|
<div className="Content">
|
||||||
<p>
|
<p>
|
||||||
{t.rich('modals.conflict.character', {
|
<Trans i18nKey="modals.conflict.character"></Trans>
|
||||||
strong: (chunks) => <strong>{chunks}</strong>
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.diagram}>
|
<div className="CharacterDiagram Diagram">
|
||||||
<ul>
|
<ul>
|
||||||
{props.conflictingCharacters?.map((character, i) => (
|
{props.conflictingCharacters?.map((character, i) => (
|
||||||
<li className={styles.character} key={`conflict-${i}`}>
|
<li className="character" key={`conflict-${i}`}>
|
||||||
<img
|
<img
|
||||||
alt={character.object.name[locale]}
|
alt={character.object.name[locale]}
|
||||||
src={imageUrl(character.object, character.uncap_level)}
|
src={imageUrl(character.object, character.uncap_level)}
|
||||||
|
|
@ -105,9 +96,9 @@ const CharacterConflictModal = (props: Props) => {
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<span className={styles.arrow}>→</span>
|
<span className="arrow">→</span>
|
||||||
<div className={styles.wrapper}>
|
<div className="wrapper">
|
||||||
<div className={styles.character}>
|
<div className="character">
|
||||||
<img
|
<img
|
||||||
alt={props.incomingCharacter?.name[locale]}
|
alt={props.incomingCharacter?.name[locale]}
|
||||||
src={imageUrl(props.incomingCharacter)}
|
src={imageUrl(props.incomingCharacter)}
|
||||||
|
|
@ -117,22 +108,20 @@ const CharacterConflictModal = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter
|
<div className="DialogFooter" ref={footerRef}>
|
||||||
rightElements={[
|
<div className="Buttons Span">
|
||||||
<Button
|
<Button
|
||||||
bound={true}
|
contained={true}
|
||||||
onClick={close}
|
onClick={close}
|
||||||
key="cancel"
|
|
||||||
text={t('buttons.cancel')}
|
text={t('buttons.cancel')}
|
||||||
/>,
|
/>
|
||||||
<Button
|
<Button
|
||||||
bound={true}
|
contained={true}
|
||||||
onClick={props.resolveConflict}
|
onClick={props.resolveConflict}
|
||||||
key="confirm"
|
|
||||||
text={t('modals.conflict.buttons.confirm')}
|
text={t('modals.conflict.buttons.confirm')}
|
||||||
/>,
|
/>
|
||||||
]}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<Overlay open={open} visible={true} />
|
<Overlay open={open} visible={true} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
39
components/CharacterGrid/index.scss
Normal file
39
components/CharacterGrid/index.scss
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
#CharacterGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
margin: auto;
|
||||||
|
max-width: $grid-width;
|
||||||
|
|
||||||
|
@include breakpoint(tablet) {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Characters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: $unit-3x;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-width: $grid-width;
|
||||||
|
isolation: isolate;
|
||||||
|
|
||||||
|
@include breakpoint(tablet) {
|
||||||
|
gap: $unit-2x;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
@media only screen
|
||||||
|
and (max-width: 500px)
|
||||||
|
and (max-height: 920px)
|
||||||
|
and (-webkit-min-device-pixel-ratio: 2) {
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > li:last-child {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,22 +2,22 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { getCookie } from 'cookies-next'
|
import { getCookie } from 'cookies-next'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
import { AxiosError, AxiosResponse } from 'axios'
|
import { AxiosError, AxiosResponse } from 'axios'
|
||||||
import debounce from 'lodash.debounce'
|
import debounce from 'lodash.debounce'
|
||||||
|
|
||||||
import Alert from '~components/common/Alert'
|
import Alert from '~components/Alert'
|
||||||
import JobSection from '~components/job/JobSection'
|
import JobSection from '~components/JobSection'
|
||||||
import CharacterUnit from '~components/character/CharacterUnit'
|
import CharacterUnit from '~components/CharacterUnit'
|
||||||
import CharacterConflictModal from '~components/character/CharacterConflictModal'
|
import CharacterConflictModal from '~components/CharacterConflictModal'
|
||||||
|
|
||||||
import type { DetailsObject, JobSkillObject, SearchableObject } from '~types'
|
import type { DetailsObject, JobSkillObject, SearchableObject } from '~types'
|
||||||
|
|
||||||
import api from '~utils/api'
|
import api from '~utils/api'
|
||||||
import { appState } from '~utils/appState'
|
import { appState } from '~utils/appState'
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
import './index.scss'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -33,7 +33,7 @@ const CharacterGrid = (props: Props) => {
|
||||||
const numCharacters: number = 5
|
const numCharacters: number = 5
|
||||||
|
|
||||||
// Localization
|
// Localization
|
||||||
const t = useTranslations('common')
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
// Cookies
|
// Cookies
|
||||||
const cookie = getCookie('account')
|
const cookie = getCookie('account')
|
||||||
|
|
@ -100,19 +100,13 @@ const CharacterGrid = (props: Props) => {
|
||||||
if (!party.id) {
|
if (!party.id) {
|
||||||
props.createParty().then((team) => {
|
props.createParty().then((team) => {
|
||||||
saveCharacter(team.id, character, position)
|
saveCharacter(team.id, character, position)
|
||||||
.then((response) => {
|
.then((response) => storeGridCharacter(response.data))
|
||||||
const data = response.data['grid_character']
|
|
||||||
storeGridCharacter(data)
|
|
||||||
})
|
|
||||||
.catch((error) => console.error(error))
|
.catch((error) => console.error(error))
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (props.editable)
|
if (props.editable)
|
||||||
saveCharacter(party.id, character, position)
|
saveCharacter(party.id, character, position)
|
||||||
.then((response) => {
|
.then((response) => handleCharacterResponse(response.data))
|
||||||
const data = response.data['grid_character']
|
|
||||||
handleCharacterResponse(data)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const axiosError = error as AxiosError
|
const axiosError = error as AxiosError
|
||||||
const response = axiosError.response
|
const response = axiosError.response
|
||||||
|
|
@ -132,7 +126,6 @@ const CharacterGrid = (props: Props) => {
|
||||||
setPosition(data.position)
|
setPosition(data.position)
|
||||||
setModalOpen(true)
|
setModalOpen(true)
|
||||||
} else {
|
} else {
|
||||||
console.log(data)
|
|
||||||
storeGridCharacter(data)
|
storeGridCharacter(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +180,6 @@ const CharacterGrid = (props: Props) => {
|
||||||
setPosition(-1)
|
setPosition(-1)
|
||||||
setConflicts([])
|
setConflicts([])
|
||||||
setIncoming(undefined)
|
setIncoming(undefined)
|
||||||
setModalOpen(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeCharacter(id: string) {
|
async function removeCharacter(id: string) {
|
||||||
|
|
@ -267,23 +259,6 @@ const CharacterGrid = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeJobSkill(position: number) {
|
|
||||||
if (party.id && props.editable) {
|
|
||||||
api
|
|
||||||
.removeJobSkill({ partyId: party.id, position: position })
|
|
||||||
.then((response) => {
|
|
||||||
// Update the current skills
|
|
||||||
const newSkills = response.data.job_skills
|
|
||||||
setJobSkills(newSkills)
|
|
||||||
appState.party.jobSkills = newSkills
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const data = error.response.data
|
|
||||||
console.log(data)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveAccessory(accessory: JobAccessory) {
|
async function saveAccessory(accessory: JobAccessory) {
|
||||||
const payload = {
|
const payload = {
|
||||||
party: {
|
party: {
|
||||||
|
|
@ -508,9 +483,7 @@ const CharacterGrid = (props: Props) => {
|
||||||
<Alert
|
<Alert
|
||||||
open={errorAlertOpen}
|
open={errorAlertOpen}
|
||||||
title={axiosError ? `${axiosError.status}` : 'Error'}
|
title={axiosError ? `${axiosError.status}` : 'Error'}
|
||||||
message={axiosError?.statusText && axiosError.statusText !== 'undefined'
|
message={t(`errors.${axiosError?.statusText.toLowerCase()}`)}
|
||||||
? t(`errors.${axiosError.statusText.toLowerCase()}`)
|
|
||||||
: t('errors.internal_server_error.description')}
|
|
||||||
cancelAction={() => setErrorAlertOpen(false)}
|
cancelAction={() => setErrorAlertOpen(false)}
|
||||||
cancelActionText={t('buttons.confirm')}
|
cancelActionText={t('buttons.confirm')}
|
||||||
/>
|
/>
|
||||||
|
|
@ -525,7 +498,7 @@ const CharacterGrid = (props: Props) => {
|
||||||
cancelAction={cancelAlert}
|
cancelAction={cancelAlert}
|
||||||
cancelActionText={'Got it'}
|
cancelActionText={'Got it'}
|
||||||
/>
|
/>
|
||||||
<div className={styles.grid}>
|
<div id="CharacterGrid">
|
||||||
<JobSection
|
<JobSection
|
||||||
job={job}
|
job={job}
|
||||||
jobSkills={jobSkills}
|
jobSkills={jobSkills}
|
||||||
|
|
@ -533,7 +506,6 @@ const CharacterGrid = (props: Props) => {
|
||||||
editable={props.editable}
|
editable={props.editable}
|
||||||
saveJob={saveJob}
|
saveJob={saveJob}
|
||||||
saveSkill={saveJobSkill}
|
saveSkill={saveJobSkill}
|
||||||
removeSkill={removeJobSkill}
|
|
||||||
saveAccessory={saveAccessory}
|
saveAccessory={saveAccessory}
|
||||||
/>
|
/>
|
||||||
<CharacterConflictModal
|
<CharacterConflictModal
|
||||||
|
|
@ -544,7 +516,7 @@ const CharacterGrid = (props: Props) => {
|
||||||
resolveConflict={resolveConflict}
|
resolveConflict={resolveConflict}
|
||||||
resetConflict={resetConflict}
|
resetConflict={resetConflict}
|
||||||
/>
|
/>
|
||||||
<ul className={styles.characters}>
|
<ul id="Characters">
|
||||||
{Array.from(Array(numCharacters)).map((x, i) => {
|
{Array.from(Array(numCharacters)).map((x, i) => {
|
||||||
return (
|
return (
|
||||||
<li key={`grid_unit_${i}`}>
|
<li key={`grid_unit_${i}`}>
|
||||||
|
|
@ -1,5 +1,20 @@
|
||||||
.content {
|
.Character.HovercardContent {
|
||||||
.mastery {
|
.title .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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Mastery {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
|
|
@ -9,7 +24,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit-half;
|
gap: $unit-half;
|
||||||
|
|
||||||
.extendedMastery {
|
.ExtendedMastery {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $unit-half;
|
gap: $unit-half;
|
||||||
|
|
@ -25,7 +40,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.awakening {
|
.Awakening {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
|
|
@ -44,4 +59,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// .Footer {
|
||||||
|
// position: sticky;
|
||||||
|
// bottom: 0;
|
||||||
|
// left: 0;
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
@ -1,28 +1,25 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
import { useRouter } from 'next/router'
|
||||||
import { getCookie } from 'cookies-next'
|
import { useTranslation } from 'next-i18next'
|
||||||
import { useTranslations } from 'next-intl'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Hovercard,
|
Hovercard,
|
||||||
HovercardContent,
|
HovercardContent,
|
||||||
HovercardTrigger,
|
HovercardTrigger,
|
||||||
} from '~components/common/Hovercard'
|
} from '~components/Hovercard'
|
||||||
import Button from '~components/common/Button'
|
import Button from '~components/Button'
|
||||||
import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon'
|
import WeaponLabelIcon from '~components/WeaponLabelIcon'
|
||||||
import UncapIndicator from '~components/uncap/UncapIndicator'
|
import UncapIndicator from '~components/UncapIndicator'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
overMastery,
|
overMastery,
|
||||||
aetherialMastery,
|
aetherialMastery,
|
||||||
permanentMastery,
|
permanentMastery,
|
||||||
} from '~data/overMastery'
|
} from '~data/overMastery'
|
||||||
|
import { characterAwakening } from '~data/awakening'
|
||||||
import { ExtendedMastery } from '~types'
|
import { ExtendedMastery } from '~types'
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
import './index.scss'
|
||||||
import HovercardHeader from '~components/HovercardHeader'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
gridCharacter: GridCharacter
|
gridCharacter: GridCharacter
|
||||||
|
|
@ -30,16 +27,34 @@ interface Props {
|
||||||
onTriggerClick: () => void
|
onTriggerClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface KeyNames {
|
||||||
|
[key: string]: {
|
||||||
|
en: string
|
||||||
|
jp: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const CharacterHovercard = (props: Props) => {
|
const CharacterHovercard = (props: Props) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const { t } = useTranslation('common')
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const t = useTranslations('common')
|
|
||||||
const routerLocale = getCookie('NEXT_LOCALE')
|
|
||||||
const locale =
|
const locale =
|
||||||
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en'
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
|
||||||
const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
|
const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
|
||||||
|
const Proficiency = [
|
||||||
|
'none',
|
||||||
|
'sword',
|
||||||
|
'dagger',
|
||||||
|
'axe',
|
||||||
|
'spear',
|
||||||
|
'bow',
|
||||||
|
'staff',
|
||||||
|
'fist',
|
||||||
|
'harp',
|
||||||
|
'gun',
|
||||||
|
'katana',
|
||||||
|
]
|
||||||
|
|
||||||
const tintElement = Element[props.gridCharacter.object.element]
|
const tintElement = Element[props.gridCharacter.object.element]
|
||||||
|
|
||||||
function goTo() {
|
function goTo() {
|
||||||
|
|
@ -49,6 +64,30 @@ const CharacterHovercard = (props: Props) => {
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const perpetuity = () => {
|
||||||
|
if (props.gridCharacter && props.gridCharacter.perpetuity) {
|
||||||
|
return <i className="Perpetuity" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function characterImage() {
|
||||||
|
let imgSrc = ''
|
||||||
|
|
||||||
|
if (props.gridCharacter) {
|
||||||
|
const character = props.gridCharacter.object
|
||||||
|
|
||||||
|
// Change the image based on the uncap level
|
||||||
|
let suffix = '01'
|
||||||
|
if (props.gridCharacter.uncap_level == 6) suffix = '04'
|
||||||
|
else if (props.gridCharacter.uncap_level == 5) suffix = '03'
|
||||||
|
else if (props.gridCharacter.uncap_level > 2) suffix = '02'
|
||||||
|
|
||||||
|
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${character.granblue_id}_${suffix}.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
return imgSrc
|
||||||
|
}
|
||||||
|
|
||||||
function masteryElement(dictionary: ItemSkill[], mastery: ExtendedMastery) {
|
function masteryElement(dictionary: ItemSkill[], mastery: ExtendedMastery) {
|
||||||
const canonicalMastery = dictionary.find(
|
const canonicalMastery = dictionary.find(
|
||||||
(item) => item.id === mastery.modifier
|
(item) => item.id === mastery.modifier
|
||||||
|
|
@ -56,7 +95,7 @@ const CharacterHovercard = (props: Props) => {
|
||||||
|
|
||||||
if (canonicalMastery) {
|
if (canonicalMastery) {
|
||||||
return (
|
return (
|
||||||
<li className={styles.extendedMastery} key={canonicalMastery.id}>
|
<li className="ExtendedMastery" key={canonicalMastery.id}>
|
||||||
<img
|
<img
|
||||||
alt={canonicalMastery.name[locale]}
|
alt={canonicalMastery.name[locale]}
|
||||||
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/mastery/${canonicalMastery.slug}.png`}
|
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/mastery/${canonicalMastery.slug}.png`}
|
||||||
|
|
@ -71,25 +110,21 @@ const CharacterHovercard = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const overMasterySection = () => {
|
const overMasterySection = () => {
|
||||||
if (
|
if (props.gridCharacter && props.gridCharacter.over_mastery) {
|
||||||
props.gridCharacter &&
|
|
||||||
props.gridCharacter.over_mastery &&
|
|
||||||
props.gridCharacter.over_mastery.length > 0
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.mastery}>
|
<section className="Mastery">
|
||||||
<h5 className={tintElement}>
|
<h5 className={tintElement}>
|
||||||
{t('modals.characters.subtitles.ring')}
|
{t('modals.characters.subtitles.ring')}
|
||||||
</h5>
|
</h5>
|
||||||
<ul>
|
<ul>
|
||||||
{[...Array(4)].map((e, i) => {
|
{[...Array(4)].map((e, i) => {
|
||||||
|
const ringIndex = i + 1
|
||||||
const ringStat: ExtendedMastery =
|
const ringStat: ExtendedMastery =
|
||||||
props.gridCharacter.over_mastery[i]
|
props.gridCharacter.over_mastery[i]
|
||||||
|
|
||||||
if (ringStat && ringStat.modifier && ringStat.modifier > 0) {
|
if (ringStat && ringStat.modifier && ringStat.modifier > 0) {
|
||||||
if (i === 0 || i === 1) {
|
if (ringIndex === 1 || ringIndex === 2) {
|
||||||
return masteryElement(overMastery.a, ringStat)
|
return masteryElement(overMastery.a, ringStat)
|
||||||
} else if (i === 2) {
|
} else if (ringIndex === 3) {
|
||||||
return masteryElement(overMastery.b, ringStat)
|
return masteryElement(overMastery.b, ringStat)
|
||||||
} else {
|
} else {
|
||||||
return masteryElement(overMastery.c, ringStat)
|
return masteryElement(overMastery.c, ringStat)
|
||||||
|
|
@ -105,12 +140,11 @@ const CharacterHovercard = (props: Props) => {
|
||||||
const aetherialMasterySection = () => {
|
const aetherialMasterySection = () => {
|
||||||
if (
|
if (
|
||||||
props.gridCharacter &&
|
props.gridCharacter &&
|
||||||
props.gridCharacter.over_mastery &&
|
|
||||||
props.gridCharacter.aetherial_mastery &&
|
props.gridCharacter.aetherial_mastery &&
|
||||||
props.gridCharacter.aetherial_mastery?.modifier > 0
|
props.gridCharacter.aetherial_mastery.modifier > 0
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<section className={styles.mastery}>
|
<section className="Mastery">
|
||||||
<h5 className={tintElement}>
|
<h5 className={tintElement}>
|
||||||
{t('modals.characters.subtitles.earring')}
|
{t('modals.characters.subtitles.earring')}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
@ -128,7 +162,7 @@ const CharacterHovercard = (props: Props) => {
|
||||||
const permanentMasterySection = () => {
|
const permanentMasterySection = () => {
|
||||||
if (props.gridCharacter && props.gridCharacter.perpetuity) {
|
if (props.gridCharacter && props.gridCharacter.perpetuity) {
|
||||||
return (
|
return (
|
||||||
<section className={styles.mastery}>
|
<section className="Mastery">
|
||||||
<h5 className={tintElement}>
|
<h5 className={tintElement}>
|
||||||
{t('modals.characters.subtitles.permanent')}
|
{t('modals.characters.subtitles.permanent')}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
@ -146,22 +180,28 @@ const CharacterHovercard = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const awakeningSection = () => {
|
const awakeningSection = () => {
|
||||||
if (props.gridCharacter.awakening) {
|
const gridAwakening = props.gridCharacter.awakening
|
||||||
const gridAwakening = props.gridCharacter.awakening
|
const awakening = characterAwakening.find(
|
||||||
|
(awakening) => awakening.id === gridAwakening?.type
|
||||||
|
)
|
||||||
|
|
||||||
|
if (gridAwakening && awakening) {
|
||||||
return (
|
return (
|
||||||
<section className={styles.awakening}>
|
<section className="Awakening">
|
||||||
<h5 className={tintElement}>
|
<h5 className={tintElement}>
|
||||||
{t('modals.characters.subtitles.awakening')}
|
{t('modals.characters.subtitles.awakening')}
|
||||||
</h5>
|
</h5>
|
||||||
<div>
|
<div>
|
||||||
{gridAwakening.type.slug !== 'character-balanced' && (
|
{gridAwakening.type > 1 ? (
|
||||||
<img
|
<img
|
||||||
alt={gridAwakening.type.name[locale]}
|
alt={awakening.name[locale]}
|
||||||
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/awakening/${gridAwakening.type.slug}.jpg`}
|
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/awakening/character_${gridAwakening.type}.jpg`}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
<strong>{`${gridAwakening.type.name[locale]}`}</strong>
|
<strong>{`${awakening.name[locale]}`}</strong>
|
||||||
{`Lv${gridAwakening.level}`}
|
{`Lv${gridAwakening.level}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -175,7 +215,7 @@ const CharacterHovercard = (props: Props) => {
|
||||||
className={tintElement}
|
className={tintElement}
|
||||||
text={t('buttons.wiki')}
|
text={t('buttons.wiki')}
|
||||||
onClick={goTo}
|
onClick={goTo}
|
||||||
bound={true}
|
contained={true}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -184,12 +224,51 @@ const CharacterHovercard = (props: Props) => {
|
||||||
<HovercardTrigger asChild onClick={props.onTriggerClick}>
|
<HovercardTrigger asChild onClick={props.onTriggerClick}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</HovercardTrigger>
|
</HovercardTrigger>
|
||||||
<HovercardContent className={styles.content} side="top">
|
<HovercardContent className="Character" side="top">
|
||||||
<HovercardHeader
|
<div className="top">
|
||||||
gridObject={props.gridCharacter}
|
<div className="title">
|
||||||
object={props.gridCharacter.object}
|
<h4>{props.gridCharacter.object.name[locale]}</h4>
|
||||||
type="character"
|
<div className="Image">
|
||||||
/>
|
{perpetuity()}
|
||||||
|
<img
|
||||||
|
alt={props.gridCharacter.object.name[locale]}
|
||||||
|
src={characterImage()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="subInfo">
|
||||||
|
<div className="icons">
|
||||||
|
<WeaponLabelIcon
|
||||||
|
labelType={Element[props.gridCharacter.object.element]}
|
||||||
|
/>
|
||||||
|
<WeaponLabelIcon
|
||||||
|
labelType={
|
||||||
|
Proficiency[
|
||||||
|
props.gridCharacter.object.proficiency.proficiency1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{props.gridCharacter.object.proficiency.proficiency2 ? (
|
||||||
|
<WeaponLabelIcon
|
||||||
|
labelType={
|
||||||
|
Proficiency[
|
||||||
|
props.gridCharacter.object.proficiency.proficiency2
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<UncapIndicator
|
||||||
|
type="character"
|
||||||
|
ulb={props.gridCharacter.object.uncap.ulb || false}
|
||||||
|
flb={props.gridCharacter.object.uncap.flb || false}
|
||||||
|
transcendenceStage={props.gridCharacter.transcendence_step}
|
||||||
|
special={props.gridCharacter.object.special}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{wikiButton}
|
{wikiButton}
|
||||||
{awakeningSection()}
|
{awakeningSection()}
|
||||||
{overMasterySection()}
|
{overMasterySection()}
|
||||||
78
components/CharacterModal/index.scss
Normal file
78
components/CharacterModal/index.scss
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
.Character.DialogContent {
|
||||||
|
gap: $unit;
|
||||||
|
min-width: 480px;
|
||||||
|
|
||||||
|
@include breakpoint(phone) {
|
||||||
|
min-width: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogHeader {
|
||||||
|
transition: 0.18s padding-top ease-in-out;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
|
&.Scrolled {
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
box-shadow: 0 1px 12px rgba(0, 0, 0, 0.34);
|
||||||
|
padding-top: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
transition: 0.2s width ease-in-out;
|
||||||
|
width: $unit-6x !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DialogTitle {
|
||||||
|
font-size: $font-large;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SubTitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mods {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-4x;
|
||||||
|
padding: 0 $unit-4x $unit-2x;
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-half;
|
||||||
|
|
||||||
|
&.inline {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: $grey-55;
|
||||||
|
font-size: $font-small;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
background-color: $grey-90;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Button {
|
||||||
|
font-size: $font-regular;
|
||||||
|
padding: ($unit * 1.5) ($unit-2x);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.btn-disabled {
|
||||||
|
background: $grey-90;
|
||||||
|
color: $grey-70;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
307
components/CharacterModal/index.tsx
Normal file
307
components/CharacterModal/index.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
// Core dependencies
|
||||||
|
import React, {
|
||||||
|
PropsWithChildren,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import { AxiosResponse } from 'axios'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
// UI dependencies
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '~components/Dialog'
|
||||||
|
import DialogContent from '~components/DialogContent'
|
||||||
|
import Button from '~components/Button'
|
||||||
|
import SelectWithInput from '~components/SelectWithInput'
|
||||||
|
import AwakeningSelect from '~components/AwakeningSelect'
|
||||||
|
import RingSelect from '~components/RingSelect'
|
||||||
|
import Switch from '~components/Switch'
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
import api from '~utils/api'
|
||||||
|
import { appState } from '~utils/appState'
|
||||||
|
import { retrieveCookies } from '~utils/retrieveCookies'
|
||||||
|
import elementalizeAetherialMastery from '~utils/elementalizeAetherialMastery'
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const emptyExtendedMastery: ExtendedMastery = {
|
||||||
|
modifier: 0,
|
||||||
|
strength: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles and icons
|
||||||
|
import CrossIcon from '~public/icons/Cross.svg'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import {
|
||||||
|
CharacterOverMastery,
|
||||||
|
ExtendedMastery,
|
||||||
|
GridCharacterObject,
|
||||||
|
} from '~types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
gridCharacter: GridCharacter
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
updateCharacter: (object: GridCharacterObject) => Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const CharacterModal = ({
|
||||||
|
gridCharacter,
|
||||||
|
children,
|
||||||
|
open: modalOpen,
|
||||||
|
onOpenChange,
|
||||||
|
updateCharacter,
|
||||||
|
}: PropsWithChildren<Props>) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
|
// Cookies
|
||||||
|
const cookies = retrieveCookies()
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [formValid, setFormValid] = useState(false)
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const headerRef = React.createRef<HTMLDivElement>()
|
||||||
|
const footerRef = React.createRef<HTMLDivElement>()
|
||||||
|
|
||||||
|
// Classes
|
||||||
|
const headerClasses = classNames({
|
||||||
|
DialogHeader: true,
|
||||||
|
Short: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Callbacks and Hooks
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(modalOpen)
|
||||||
|
}, [modalOpen])
|
||||||
|
|
||||||
|
// Character properties: Perpetuity
|
||||||
|
const [perpetuity, setPerpetuity] = useState(false)
|
||||||
|
|
||||||
|
// Character properties: Ring
|
||||||
|
const [rings, setRings] = useState<CharacterOverMastery>({
|
||||||
|
1: { ...emptyExtendedMastery, modifier: 1 },
|
||||||
|
2: { ...emptyExtendedMastery, modifier: 2 },
|
||||||
|
3: emptyExtendedMastery,
|
||||||
|
4: emptyExtendedMastery,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Character properties: Earrings
|
||||||
|
const [earring, setEarring] = useState<ExtendedMastery>(emptyExtendedMastery)
|
||||||
|
|
||||||
|
// Character properties: Awakening
|
||||||
|
const [awakeningType, setAwakeningType] = useState(0)
|
||||||
|
const [awakeningLevel, setAwakeningLevel] = useState(0)
|
||||||
|
|
||||||
|
// Character properties: Transcendence
|
||||||
|
const [transcendenceStep, setTranscendenceStep] = useState(0)
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
useEffect(() => {
|
||||||
|
if (gridCharacter.aetherial_mastery) {
|
||||||
|
setEarring({
|
||||||
|
modifier: gridCharacter.aetherial_mastery.modifier,
|
||||||
|
strength: gridCharacter.aetherial_mastery.strength,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setAwakeningType(gridCharacter.awakening.type)
|
||||||
|
setAwakeningLevel(gridCharacter.awakening.level)
|
||||||
|
setPerpetuity(gridCharacter.perpetuity)
|
||||||
|
}, [gridCharacter])
|
||||||
|
|
||||||
|
// Prepare the GridWeaponObject to send to the server
|
||||||
|
function prepareObject() {
|
||||||
|
let object: GridCharacterObject = {
|
||||||
|
character: {
|
||||||
|
ring1: {
|
||||||
|
modifier: rings[1].modifier,
|
||||||
|
strength: rings[1].strength,
|
||||||
|
},
|
||||||
|
ring2: {
|
||||||
|
modifier: rings[2].modifier,
|
||||||
|
strength: rings[2].strength,
|
||||||
|
},
|
||||||
|
ring3: {
|
||||||
|
modifier: rings[3].modifier,
|
||||||
|
strength: rings[3].strength,
|
||||||
|
},
|
||||||
|
ring4: {
|
||||||
|
modifier: rings[4].modifier,
|
||||||
|
strength: rings[4].strength,
|
||||||
|
},
|
||||||
|
earring: {
|
||||||
|
modifier: earring.modifier,
|
||||||
|
strength: earring.strength,
|
||||||
|
},
|
||||||
|
awakening: {
|
||||||
|
type: awakeningType,
|
||||||
|
level: awakeningLevel,
|
||||||
|
},
|
||||||
|
transcendence_step: transcendenceStep,
|
||||||
|
perpetuity: perpetuity,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: UI state management
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
setOpen(open)
|
||||||
|
onOpenChange(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods: Receive data from components
|
||||||
|
function receiveRingValues(overMastery: CharacterOverMastery) {
|
||||||
|
setRings(overMastery)
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiveEarringValues(
|
||||||
|
earringModifier: number,
|
||||||
|
earringStrength: number
|
||||||
|
) {
|
||||||
|
setEarring({
|
||||||
|
modifier: earringModifier,
|
||||||
|
strength: earringStrength,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCheckedChange(checked: boolean) {
|
||||||
|
setPerpetuity(checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateCharacter() {
|
||||||
|
await updateCharacter(prepareObject())
|
||||||
|
|
||||||
|
setOpen(false)
|
||||||
|
if (onOpenChange) onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiveAwakeningValues(type: number, level: number) {
|
||||||
|
setAwakeningType(type)
|
||||||
|
setAwakeningLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiveValidity(isValid: boolean) {
|
||||||
|
setFormValid(isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ringSelect = () => {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3>{t('modals.characters.subtitles.ring')}</h3>
|
||||||
|
<RingSelect
|
||||||
|
gridCharacter={gridCharacter}
|
||||||
|
sendValues={receiveRingValues}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const earringSelect = () => {
|
||||||
|
const earringData = elementalizeAetherialMastery(gridCharacter)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3>{t('modals.characters.subtitles.earring')}</h3>
|
||||||
|
<SelectWithInput
|
||||||
|
object="earring"
|
||||||
|
dataSet={earringData}
|
||||||
|
selectValue={earring.modifier ? earring.modifier : 0}
|
||||||
|
inputValue={earring.strength ? earring.strength : 0}
|
||||||
|
sendValidity={receiveValidity}
|
||||||
|
sendValues={receiveEarringValues}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const awakeningSelect = () => {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3>{t('modals.characters.subtitles.awakening')}</h3>
|
||||||
|
<AwakeningSelect
|
||||||
|
object="character"
|
||||||
|
type={awakeningType}
|
||||||
|
level={awakeningLevel}
|
||||||
|
sendValidity={receiveValidity}
|
||||||
|
sendValues={receiveAwakeningValues}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const perpetuitySwitch = () => {
|
||||||
|
return (
|
||||||
|
<section className="inline">
|
||||||
|
<h3>{t('modals.characters.subtitles.permanent')}</h3>
|
||||||
|
<Switch onCheckedChange={handleCheckedChange} checked={perpetuity} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent
|
||||||
|
className="Character"
|
||||||
|
headerref={headerRef}
|
||||||
|
footerref={footerRef}
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
onEscapeKeyDown={() => {}}
|
||||||
|
>
|
||||||
|
<div className={headerClasses} ref={headerRef}>
|
||||||
|
<img
|
||||||
|
alt={gridCharacter.object.name[locale]}
|
||||||
|
className="DialogImage"
|
||||||
|
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-square/${gridCharacter.object.granblue_id}_01.jpg`}
|
||||||
|
/>
|
||||||
|
<div className="DialogTop">
|
||||||
|
<DialogTitle className="SubTitle">
|
||||||
|
{t('modals.characters.title')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogTitle className="DialogTitle">
|
||||||
|
{gridCharacter.object.name[locale]}
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogClose className="DialogClose" asChild>
|
||||||
|
<span>
|
||||||
|
<CrossIcon />
|
||||||
|
</span>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mods">
|
||||||
|
{perpetuitySwitch()}
|
||||||
|
{ringSelect()}
|
||||||
|
{earringSelect()}
|
||||||
|
{awakeningSelect()}
|
||||||
|
</div>
|
||||||
|
<div className="DialogFooter" ref={footerRef}>
|
||||||
|
<Button
|
||||||
|
contained={true}
|
||||||
|
onClick={handleUpdateCharacter}
|
||||||
|
disabled={!formValid}
|
||||||
|
text={t('modals.characters.buttons.confirm')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CharacterModal
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
.result {
|
.CharacterResult {
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
background: var(--button-contained-bg);
|
background: var(--button-contained-bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
.info h5 {
|
.Info h5 {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
width: 120px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.Info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
@ -32,7 +32,6 @@
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: $font-medium;
|
font-size: $font-medium;
|
||||||
font-weight: $medium;
|
font-weight: $medium;
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.UncapIndicator {
|
.UncapIndicator {
|
||||||
52
components/CharacterResult/index.tsx
Normal file
52
components/CharacterResult/index.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
import UncapIndicator from '~components/UncapIndicator'
|
||||||
|
import WeaponLabelIcon from '~components/WeaponLabelIcon'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Character
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
|
||||||
|
|
||||||
|
const CharacterResult = (props: Props) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const locale =
|
||||||
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
|
||||||
|
const character = props.data
|
||||||
|
|
||||||
|
const characterUrl = () => {
|
||||||
|
let url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${character.granblue_id}_01.jpg`
|
||||||
|
|
||||||
|
if (character.granblue_id === '3030182000') {
|
||||||
|
url = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${character.granblue_id}_01_01.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="CharacterResult" onClick={props.onClick}>
|
||||||
|
<img alt={character.name[locale]} src={characterUrl()} />
|
||||||
|
<div className="Info">
|
||||||
|
<h5>{character.name[locale]}</h5>
|
||||||
|
<UncapIndicator
|
||||||
|
type="character"
|
||||||
|
flb={character.uncap.flb}
|
||||||
|
ulb={character.uncap.ulb}
|
||||||
|
special={character.special}
|
||||||
|
/>
|
||||||
|
<div className="tags">
|
||||||
|
<WeaponLabelIcon labelType={Element[character.element]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CharacterResult
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslation } from 'next-i18next'
|
||||||
|
|
||||||
import cloneDeep from 'lodash.clonedeep'
|
import cloneDeep from 'lodash.clonedeep'
|
||||||
|
|
||||||
import SearchFilter from '~components/search/SearchFilter'
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
import SearchFilterCheckboxItem from '~components/search/SearchFilterCheckboxItem'
|
|
||||||
|
|
||||||
|
import SearchFilter from '~components/SearchFilter'
|
||||||
|
import SearchFilterCheckboxItem from '~components/SearchFilterCheckboxItem'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
import {
|
import {
|
||||||
emptyElementState,
|
emptyElementState,
|
||||||
emptyProficiencyState,
|
emptyProficiencyState,
|
||||||
|
|
@ -12,14 +16,12 @@ import {
|
||||||
} from '~utils/emptyStates'
|
} from '~utils/emptyStates'
|
||||||
import { elements, proficiencies, rarities } from '~utils/stateValues'
|
import { elements, proficiencies, rarities } from '~utils/stateValues'
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sendFilters: (filters: { [key: string]: number[] }) => void
|
sendFilters: (filters: { [key: string]: number[] }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CharacterSearchFilterBar = (props: Props) => {
|
const CharacterSearchFilterBar = (props: Props) => {
|
||||||
const t = useTranslations('common')
|
const { t } = useTranslation('common')
|
||||||
|
|
||||||
const [rarityMenu, setRarityMenu] = useState(false)
|
const [rarityMenu, setRarityMenu] = useState(false)
|
||||||
const [elementMenu, setElementMenu] = useState(false)
|
const [elementMenu, setElementMenu] = useState(false)
|
||||||
|
|
@ -142,90 +144,121 @@ const CharacterSearchFilterBar = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<SearchFilter
|
<SearchFilter
|
||||||
label={`${t('filters.labels.proficiency')} ${proficiency}`}
|
label={`${t('filters.labels.proficiency')} ${proficiency}`}
|
||||||
display="grid"
|
|
||||||
numSelected={numSelected}
|
numSelected={numSelected}
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
>
|
>
|
||||||
{Array.from(Array(proficiencies.length)).map((x, i) => {
|
<DropdownMenu.Label className="Label">{`${t(
|
||||||
const checked =
|
'filters.labels.proficiency'
|
||||||
proficiency == 1
|
)} ${proficiency}`}</DropdownMenu.Label>
|
||||||
? proficiency1State[proficiencies[i]].checked
|
<section>
|
||||||
: proficiency2State[proficiencies[i]].checked
|
<DropdownMenu.Group className="Group">
|
||||||
|
{Array.from(Array(proficiencies.length / 2)).map((x, i) => {
|
||||||
|
const checked =
|
||||||
|
proficiency == 1
|
||||||
|
? proficiency1State[proficiencies[i]].checked
|
||||||
|
: proficiency2State[proficiencies[i]].checked
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchFilterCheckboxItem
|
<SearchFilterCheckboxItem
|
||||||
key={proficiencies[i]}
|
key={proficiencies[i]}
|
||||||
onCheckedChange={onCheckedChange}
|
onCheckedChange={onCheckedChange}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
valueKey={proficiencies[i]}
|
valueKey={proficiencies[i]}
|
||||||
>
|
>
|
||||||
{t(`proficiencies.${proficiencies[i]}`)}
|
{t(`proficiencies.${proficiencies[i]}`)}
|
||||||
</SearchFilterCheckboxItem>
|
</SearchFilterCheckboxItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Group className="Group">
|
||||||
|
{Array.from(Array(proficiencies.length / 2)).map((x, i) => {
|
||||||
|
const checked =
|
||||||
|
proficiency == 1
|
||||||
|
? proficiency1State[
|
||||||
|
proficiencies[i + proficiencies.length / 2]
|
||||||
|
].checked
|
||||||
|
: proficiency2State[
|
||||||
|
proficiencies[i + proficiencies.length / 2]
|
||||||
|
].checked
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchFilterCheckboxItem
|
||||||
|
key={proficiencies[i + proficiencies.length / 2]}
|
||||||
|
onCheckedChange={onCheckedChange}
|
||||||
|
checked={checked}
|
||||||
|
valueKey={proficiencies[i + proficiencies.length / 2]}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
`proficiencies.${
|
||||||
|
proficiencies[i + proficiencies.length / 2]
|
||||||
|
}`
|
||||||
|
)}
|
||||||
|
</SearchFilterCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
</section>
|
||||||
</SearchFilter>
|
</SearchFilter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rarityFilter = (
|
|
||||||
<SearchFilter
|
|
||||||
label={t('filters.labels.rarity')}
|
|
||||||
display="list"
|
|
||||||
numSelected={
|
|
||||||
Object.values(rarityState)
|
|
||||||
.map((x) => x.checked)
|
|
||||||
.filter(Boolean).length
|
|
||||||
}
|
|
||||||
open={rarityMenu}
|
|
||||||
onOpenChange={rarityMenuOpened}
|
|
||||||
>
|
|
||||||
{Array.from(Array(rarities.length)).map((x, i) => {
|
|
||||||
return (
|
|
||||||
<SearchFilterCheckboxItem
|
|
||||||
key={rarities[i]}
|
|
||||||
onCheckedChange={handleRarityChange}
|
|
||||||
checked={rarityState[rarities[i]].checked}
|
|
||||||
valueKey={rarities[i]}
|
|
||||||
>
|
|
||||||
{t(`rarities.${rarities[i]}`)}
|
|
||||||
</SearchFilterCheckboxItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</SearchFilter>
|
|
||||||
)
|
|
||||||
|
|
||||||
const elementFilter = (
|
|
||||||
<SearchFilter
|
|
||||||
label={t('filters.labels.element')}
|
|
||||||
display="list"
|
|
||||||
numSelected={
|
|
||||||
Object.values(elementState)
|
|
||||||
.map((x) => x.checked)
|
|
||||||
.filter(Boolean).length
|
|
||||||
}
|
|
||||||
open={elementMenu}
|
|
||||||
onOpenChange={elementMenuOpened}
|
|
||||||
>
|
|
||||||
{Array.from(Array(elements.length)).map((x, i) => {
|
|
||||||
return (
|
|
||||||
<SearchFilterCheckboxItem
|
|
||||||
key={elements[i]}
|
|
||||||
onCheckedChange={handleElementChange}
|
|
||||||
checked={elementState[elements[i]].checked}
|
|
||||||
valueKey={elements[i]}
|
|
||||||
>
|
|
||||||
{t(`elements.${elements[i]}`)}
|
|
||||||
</SearchFilterCheckboxItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</SearchFilter>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.filterBar}>
|
<div className="SearchFilterBar">
|
||||||
{rarityFilter}
|
<SearchFilter
|
||||||
{elementFilter}
|
label={t('filters.labels.rarity')}
|
||||||
|
numSelected={
|
||||||
|
Object.values(rarityState)
|
||||||
|
.map((x) => x.checked)
|
||||||
|
.filter(Boolean).length
|
||||||
|
}
|
||||||
|
open={rarityMenu}
|
||||||
|
onOpenChange={rarityMenuOpened}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label className="Label">
|
||||||
|
{t('filters.labels.rarity')}
|
||||||
|
</DropdownMenu.Label>
|
||||||
|
{Array.from(Array(rarities.length)).map((x, i) => {
|
||||||
|
return (
|
||||||
|
<SearchFilterCheckboxItem
|
||||||
|
key={rarities[i]}
|
||||||
|
onCheckedChange={handleRarityChange}
|
||||||
|
checked={rarityState[rarities[i]].checked}
|
||||||
|
valueKey={rarities[i]}
|
||||||
|
>
|
||||||
|
{t(`rarities.${rarities[i]}`)}
|
||||||
|
</SearchFilterCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SearchFilter>
|
||||||
|
|
||||||
|
<SearchFilter
|
||||||
|
label={t('filters.labels.element')}
|
||||||
|
numSelected={
|
||||||
|
Object.values(elementState)
|
||||||
|
.map((x) => x.checked)
|
||||||
|
.filter(Boolean).length
|
||||||
|
}
|
||||||
|
open={elementMenu}
|
||||||
|
onOpenChange={elementMenuOpened}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label className="Label">
|
||||||
|
{t('filters.labels.element')}
|
||||||
|
</DropdownMenu.Label>
|
||||||
|
{Array.from(Array(elements.length)).map((x, i) => {
|
||||||
|
return (
|
||||||
|
<SearchFilterCheckboxItem
|
||||||
|
key={elements[i]}
|
||||||
|
onCheckedChange={handleElementChange}
|
||||||
|
checked={elementState[elements[i]].checked}
|
||||||
|
valueKey={elements[i]}
|
||||||
|
>
|
||||||
|
{t(`elements.${elements[i]}`)}
|
||||||
|
</SearchFilterCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SearchFilter>
|
||||||
|
|
||||||
{renderProficiencyFilter(1)}
|
{renderProficiencyFilter(1)}
|
||||||
{renderProficiencyFilter(2)}
|
{renderProficiencyFilter(2)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
.unit {
|
.CharacterUnit {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: $unit * 4;
|
margin-bottom: $unit * 4;
|
||||||
|
|
||||||
&.editable .image:hover {
|
&.editable .CharacterImage:hover {
|
||||||
border: $hover-stroke;
|
border: $hover-stroke;
|
||||||
box-shadow: $hover-shadow;
|
box-shadow: $hover-shadow;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.CharacterImage {
|
||||||
aspect-ratio: 131 / 273;
|
aspect-ratio: 131 / 273;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid rgba(0, 0, 0, 0);
|
border: 1px solid rgba(0, 0, 0, 0);
|
||||||
|
|
@ -92,17 +92,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.CharacterName {
|
||||||
@include breakpoint(phone) {
|
@include breakpoint(phone) {
|
||||||
font-size: $font-tiny;
|
font-size: $font-tiny;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .perpetuity.empty {
|
&:hover .Perpetuity.Empty {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.perpetuity {
|
.Perpetuity {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background-image: url('/icons/perpetuity/filled.svg');
|
background-image: url('/icons/perpetuity/filled.svg');
|
||||||
background-size: $unit-4x $unit-4x;
|
background-size: $unit-4x $unit-4x;
|
||||||
|
|
@ -118,7 +118,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.empty {
|
&.Empty {
|
||||||
background-image: url('/icons/perpetuity/empty.svg');
|
background-image: url('/icons/perpetuity/empty.svg');
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
||||||
|
|
@ -1,26 +1,22 @@
|
||||||
'use client'
|
import React, { MouseEvent, useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
|
||||||
import { getCookie } from 'cookies-next'
|
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useTranslations } from 'next-intl'
|
import { Trans, useTranslation } from 'next-i18next'
|
||||||
import { AxiosResponse } from 'axios'
|
import { AxiosResponse } from 'axios'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import cloneDeep from 'lodash.clonedeep'
|
|
||||||
|
|
||||||
import Alert from '~components/common/Alert'
|
import Alert from '~components/Alert'
|
||||||
import Button from '~components/common/Button'
|
import Button from '~components/Button'
|
||||||
import CharacterHovercard from '~components/character/CharacterHovercard'
|
import CharacterHovercard from '~components/CharacterHovercard'
|
||||||
import CharacterModal from '~components/character/CharacterModal'
|
import CharacterModal from '~components/CharacterModal'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
} from '~components/common/ContextMenu'
|
} from '~components/ContextMenu'
|
||||||
import ContextMenuItem from '~components/common/ContextMenuItem'
|
import ContextMenuItem from '~components/ContextMenuItem'
|
||||||
import SearchModal from '~components/search/SearchModal'
|
import SearchModal from '~components/SearchModal'
|
||||||
import UncapIndicator from '~components/uncap/UncapIndicator'
|
import UncapIndicator from '~components/UncapIndicator'
|
||||||
|
|
||||||
import api from '~utils/api'
|
import api from '~utils/api'
|
||||||
import { appState } from '~utils/appState'
|
import { appState } from '~utils/appState'
|
||||||
|
|
@ -30,13 +26,12 @@ import SettingsIcon from '~public/icons/Settings.svg'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type {
|
import type {
|
||||||
CharacterOverMastery,
|
|
||||||
GridCharacterObject,
|
GridCharacterObject,
|
||||||
PerpetuityObject,
|
PerpetuityObject,
|
||||||
SearchableObject,
|
SearchableObject,
|
||||||
} from '~types'
|
} from '~types'
|
||||||
|
|
||||||
import styles from './index.module.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
gridCharacter?: GridCharacter
|
gridCharacter?: GridCharacter
|
||||||
|
|
@ -58,13 +53,10 @@ const CharacterUnit = ({
|
||||||
updateTranscendence,
|
updateTranscendence,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
// Translations and locale
|
// Translations and locale
|
||||||
const t = useTranslations('common')
|
const { t } = useTranslation('common')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const routerLocale = getCookie('NEXT_LOCALE')
|
|
||||||
const locale =
|
const locale =
|
||||||
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en'
|
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||||
|
|
||||||
// State snapshot
|
// State snapshot
|
||||||
const { party, grid } = useSnapshot(appState)
|
const { party, grid } = useSnapshot(appState)
|
||||||
|
|
@ -80,10 +72,14 @@ const CharacterUnit = ({
|
||||||
|
|
||||||
// Classes
|
// Classes
|
||||||
const classes = classNames({
|
const classes = classNames({
|
||||||
unit: true,
|
CharacterUnit: true,
|
||||||
[styles.unit]: true,
|
editable: editable,
|
||||||
[styles.editable]: editable,
|
filled: gridCharacter !== undefined,
|
||||||
[styles.filled]: gridCharacter !== undefined,
|
})
|
||||||
|
|
||||||
|
const buttonClasses = classNames({
|
||||||
|
Options: true,
|
||||||
|
Clicked: contextMenuOpen,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Other
|
// Other
|
||||||
|
|
@ -151,20 +147,7 @@ const CharacterUnit = ({
|
||||||
// Save the server's response to state
|
// Save the server's response to state
|
||||||
function processResult(response: AxiosResponse) {
|
function processResult(response: AxiosResponse) {
|
||||||
const gridCharacter: GridCharacter = response.data
|
const gridCharacter: GridCharacter = response.data
|
||||||
let character = cloneDeep(gridCharacter)
|
appState.grid.characters[gridCharacter.position] = gridCharacter
|
||||||
|
|
||||||
if (character.over_mastery) {
|
|
||||||
const overMastery: CharacterOverMastery = [
|
|
||||||
gridCharacter.over_mastery[0],
|
|
||||||
gridCharacter.over_mastery[1],
|
|
||||||
gridCharacter.over_mastery[2],
|
|
||||||
gridCharacter.over_mastery[3],
|
|
||||||
]
|
|
||||||
|
|
||||||
character.over_mastery = overMastery
|
|
||||||
}
|
|
||||||
|
|
||||||
appState.grid.characters[gridCharacter.position] = character
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function processError(error: any) {
|
function processError(error: any) {
|
||||||
|
|
@ -209,7 +192,7 @@ const CharacterUnit = ({
|
||||||
suffix = `${suffix}_0${element}`
|
suffix = `${suffix}_0${element}`
|
||||||
}
|
}
|
||||||
|
|
||||||
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-main/${character.granblue_id}_${suffix}.jpg`
|
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-main/${character.granblue_id}_${suffix}.jpg`
|
||||||
}
|
}
|
||||||
|
|
||||||
setImageUrl(imgSrc)
|
setImageUrl(imgSrc)
|
||||||
|
|
@ -236,10 +219,8 @@ const CharacterUnit = ({
|
||||||
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
|
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
active={contextMenuOpen}
|
|
||||||
floating={true}
|
|
||||||
leftAccessoryIcon={<SettingsIcon />}
|
leftAccessoryIcon={<SettingsIcon />}
|
||||||
className="options"
|
className={buttonClasses}
|
||||||
onClick={handleButtonClicked}
|
onClick={handleButtonClicked}
|
||||||
/>
|
/>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|
@ -268,12 +249,11 @@ const CharacterUnit = ({
|
||||||
cancelAction={() => setAlertOpen(false)}
|
cancelAction={() => setAlertOpen(false)}
|
||||||
cancelActionText={t('buttons.cancel')}
|
cancelActionText={t('buttons.cancel')}
|
||||||
message={
|
message={
|
||||||
<>
|
<Trans i18nKey="modals.characters.messages.remove">
|
||||||
{t.rich('modals.characters.messages.remove', {
|
Are you sure you want to remove{' '}
|
||||||
character: gridCharacter?.object.name[locale] || '',
|
<strong>{{ character: gridCharacter?.object.name[locale] }}</strong>{' '}
|
||||||
strong: (chunks) => <strong>{chunks}</strong>
|
from your team?
|
||||||
})}
|
</Trans>
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -298,8 +278,8 @@ const CharacterUnit = ({
|
||||||
const perpetuity = () => {
|
const perpetuity = () => {
|
||||||
if (gridCharacter) {
|
if (gridCharacter) {
|
||||||
const classes = classNames({
|
const classes = classNames({
|
||||||
[styles.perpetuity]: true,
|
Perpetuity: true,
|
||||||
[styles.empty]: !gridCharacter.perpetuity,
|
Empty: !gridCharacter.perpetuity,
|
||||||
})
|
})
|
||||||
|
|
||||||
return <i className={classes} onClick={handlePerpetuityClick} />
|
return <i className={classes} onClick={handlePerpetuityClick} />
|
||||||
|
|
@ -317,13 +297,13 @@ const CharacterUnit = ({
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
className={styles.image}
|
className="CharacterImage"
|
||||||
tabIndex={gridCharacter ? gridCharacter.position * 7 : 0}
|
tabIndex={gridCharacter ? gridCharacter.position * 7 : 0}
|
||||||
onClick={openSearchModal}
|
onClick={openSearchModal}
|
||||||
>
|
>
|
||||||
{image}
|
{image}
|
||||||
{editable ? (
|
{editable ? (
|
||||||
<span className={styles.icon}>
|
<span className="icon">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -366,7 +346,7 @@ const CharacterUnit = ({
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
)}
|
)}
|
||||||
<h3 className={styles.name}>{character?.name[locale]}</h3>
|
<h3 className="CharacterName">{character?.name[locale]}</h3>
|
||||||
</div>
|
</div>
|
||||||
{searchModal()}
|
{searchModal()}
|
||||||
</>
|
</>
|
||||||
11
components/ContentUpdate/index.scss
Normal file
11
components/ContentUpdate/index.scss
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
.Content.Version {
|
||||||
|
.Contents {
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Notes h4 {
|
||||||
|
font-weight: $medium;
|
||||||
|
font-size: $font-regular;
|
||||||
|
margin-bottom: $unit-2x;
|
||||||
|
}
|
||||||
|
}
|
||||||
143
components/ContentUpdate/index.tsx
Normal file
143
components/ContentUpdate/index.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import ChangelogUnit from '~components/ChangelogUnit'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface UpdateObject {
|
||||||
|
character?: string[]
|
||||||
|
summon?: string[]
|
||||||
|
weapon?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
version: string
|
||||||
|
dateString: string
|
||||||
|
event: string
|
||||||
|
newItems?: UpdateObject
|
||||||
|
uncappedItems?: UpdateObject
|
||||||
|
numNotes: number
|
||||||
|
}
|
||||||
|
const ContentUpdate = ({
|
||||||
|
version,
|
||||||
|
dateString,
|
||||||
|
event,
|
||||||
|
newItems,
|
||||||
|
uncappedItems,
|
||||||
|
numNotes,
|
||||||
|
}: Props) => {
|
||||||
|
const { t: updates } = useTranslation('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={`${key}s`}>
|
||||||
|
<h4>{updates(`labels.${key}s`)}</h4>
|
||||||
|
<div className="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} image="03" />
|
||||||
|
) : (
|
||||||
|
<ChangelogUnit id={id} type={key} />
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
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={`${key}s`}>
|
||||||
|
<h4>{updates(`labels.uncaps.${key}s`)}</h4>
|
||||||
|
<div className="items">{uncapItemElements(key)}</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="Content Version" data-version={version}>
|
||||||
|
<div className="Header">
|
||||||
|
<h3>{`${updates('events.date', {
|
||||||
|
year: date.getFullYear(),
|
||||||
|
month: `${date.getMonth() + 1}`.padStart(2, '0'),
|
||||||
|
})} ${updates(event)}`}</h3>
|
||||||
|
<time>{dateString}</time>
|
||||||
|
</div>
|
||||||
|
<div className="Contents">
|
||||||
|
{newItemSection('character')}
|
||||||
|
{uncapItemSection('character')}
|
||||||
|
{newItemSection('weapon')}
|
||||||
|
{uncapItemSection('weapon')}
|
||||||
|
{newItemSection('summon')}
|
||||||
|
{uncapItemSection('summon')}
|
||||||
|
</div>
|
||||||
|
{numNotes > 0 ? (
|
||||||
|
<div className="Notes">
|
||||||
|
<section>
|
||||||
|
<h4>{updates('labels.updates')}</h4>
|
||||||
|
<ul className="Bare Contents">
|
||||||
|
{[...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,4 +1,4 @@
|
||||||
.menu {
|
.ContextMenu {
|
||||||
background: var(--menu-bg);
|
background: var(--menu-bg);
|
||||||
border-radius: $input-corner;
|
border-radius: $input-corner;
|
||||||
padding: $unit 0;
|
padding: $unit 0;
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue