Compare commits
7 commits
main
...
legfest-20
| Author | SHA1 | Date | |
|---|---|---|---|
| 6698af1177 | |||
| 8eb7d535d7 | |||
| ca0d14b5d6 | |||
| fce8773106 | |||
| f793ba02db | |||
| b1236a1f97 | |||
| 3690bcf2a5 |
596 changed files with 20795 additions and 50532 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",
|
||||
"rules": {
|
||||
// Other rules
|
||||
"@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
|
||||
public/images/weapon*
|
||||
public/images/summon*
|
||||
public/images/character*
|
||||
public/images/chara*
|
||||
public/images/job*
|
||||
public/images/awakening*
|
||||
public/images/ax*
|
||||
public/images/accessory*
|
||||
public/images/mastery*
|
||||
public/images/updates*
|
||||
public/images/guidebooks*
|
||||
public/images/raids*
|
||||
public/images/gacha*
|
||||
public/images/previews*
|
||||
public/image/profiles*
|
||||
|
||||
# Typescript v1 declaration files
|
||||
typings/
|
||||
|
|
@ -88,7 +83,3 @@ typings/
|
|||
# DS_Store
|
||||
.DS_Store
|
||||
*.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,
|
||||
"i18n-ally.localesPaths": ["public/locales"],
|
||||
"i18n-ally.keystyle": "nested"
|
||||
}
|
||||
"git.ignoreLimitWarning": true
|
||||
}
|
||||
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/
|
||||
├─ awakening/
|
||||
├─ ax/
|
||||
├─ character-main/
|
||||
├─ character-grid/
|
||||
├─ character-square/
|
||||
├─ guidebooks/
|
||||
├─ chara-main/
|
||||
├─ chara-grid/
|
||||
├─ chara-square/
|
||||
├─ jobs/
|
||||
├─ job-icons/
|
||||
├─ job-portraits/
|
||||
├─ job-skills/
|
||||
├─ labels/
|
||||
├─ mastery/
|
||||
├─ placeholders/
|
||||
├─ raids/
|
||||
├─ summon-main/
|
||||
├─ summon-grid/
|
||||
├─ summon-square/
|
||||
├─ updates/
|
||||
├─ weapon-main/
|
||||
├─ weapon-grid/
|
||||
├─ weapon-keys/
|
||||
├─ weapon-square/
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 Head from 'next/head'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
|
||||
interface Props {
|
||||
page: string
|
||||
|
|
@ -8,7 +8,7 @@ interface Props {
|
|||
|
||||
const AboutHead = ({ page }: Props) => {
|
||||
// Import translations
|
||||
const t = useTranslations('common')
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
// State
|
||||
const [currentPage, setCurrentPage] = useState('about')
|
||||
|
|
@ -26,7 +26,7 @@ const AboutHead = ({ page }: Props) => {
|
|||
name="description"
|
||||
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" />
|
||||
|
||||
{/* OpenGraph */}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
.about {
|
||||
.About.PageContent {
|
||||
$width: 520px;
|
||||
padding-bottom: $unit-12x;
|
||||
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
gap: $unit-2x;
|
||||
z-index: 5;
|
||||
|
||||
.hero {
|
||||
.Hero {
|
||||
position: absolute;
|
||||
width: 40vw;
|
||||
height: 80vh;
|
||||
|
|
@ -55,10 +55,22 @@
|
|||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.links {
|
||||
.Links {
|
||||
display: grid;
|
||||
gap: $unit;
|
||||
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 { getCookie, setCookie } from 'cookies-next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Dialog } from '~components/common/Dialog'
|
||||
import DialogHeader from '~components/common/DialogHeader'
|
||||
import DialogFooter from '~components/common/DialogFooter'
|
||||
import DialogContent from '~components/common/DialogContent'
|
||||
import Button from '~components/common/Button'
|
||||
import SelectItem from '~components/common/SelectItem'
|
||||
import SelectTableField from '~components/common/SelectTableField'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '~components/Dialog'
|
||||
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 changeLanguage from 'utils/changeLanguage'
|
||||
import { accountState } from '~utils/accountState'
|
||||
import { pictureData } from '~utils/pictureData'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
import SwitchTableField from '~components/common/SwitchTableField'
|
||||
import CrossIcon from '~public/icons/Cross.svg'
|
||||
import './index.scss'
|
||||
|
||||
type StateVariables = {
|
||||
[key: string]: boolean
|
||||
picture: boolean
|
||||
gender: boolean
|
||||
language: boolean
|
||||
theme: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
|
|
@ -30,26 +41,40 @@ interface Props {
|
|||
language?: string
|
||||
theme?: string
|
||||
private?: boolean
|
||||
role?: number
|
||||
bahamutMode?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
||||
function AccountModal(props: Props, forwardedRef) {
|
||||
// Localization
|
||||
const t = useTranslations('common')
|
||||
const { t } = useTranslation('common')
|
||||
const router = useRouter()
|
||||
// In App Router, locale is handled via cookies
|
||||
const currentLocale = getCookie('NEXT_LOCALE') as string || 'en'
|
||||
const locale = ['en', 'ja'].includes(currentLocale) ? currentLocale : 'en'
|
||||
const locale =
|
||||
router.locale && ['en', 'ja'].includes(router.locale)
|
||||
? router.locale
|
||||
: 'en'
|
||||
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
const [mounted, setMounted] = useState(false)
|
||||
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
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectOpenState, setSelectOpenState] = useState<StateVariables>({
|
||||
picture: false,
|
||||
gender: false,
|
||||
language: false,
|
||||
theme: false,
|
||||
})
|
||||
|
||||
// Values
|
||||
const [username, setUsername] = useState(props.username || '')
|
||||
|
|
@ -57,7 +82,7 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
|||
const [language, setLanguage] = useState(props.language || '')
|
||||
const [gender, setGender] = useState(props.gender || 0)
|
||||
const [theme, setTheme] = useState(props.theme || 'system')
|
||||
const [bahamutMode, setBahamutMode] = useState(props.bahamutMode || false)
|
||||
// const [privateProfile, setPrivateProfile] = useState(false)
|
||||
|
||||
// Setup
|
||||
const [pictureOpen, setPictureOpen] = useState(false)
|
||||
|
|
@ -123,6 +148,7 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
|||
language: language,
|
||||
gender: gender,
|
||||
theme: theme,
|
||||
// private: privateProfile,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +166,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
|||
gender: user.gender,
|
||||
language: user.language,
|
||||
theme: user.theme,
|
||||
bahamut: bahamutMode,
|
||||
}
|
||||
|
||||
const expiresAt = new Date()
|
||||
|
|
@ -151,7 +176,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
|||
id: user.id,
|
||||
username: user.username,
|
||||
granblueId: '',
|
||||
role: user.role,
|
||||
avatar: {
|
||||
picture: user.avatar.picture,
|
||||
element: user.avatar.element,
|
||||
|
|
@ -159,13 +183,11 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
|||
language: user.language,
|
||||
theme: user.theme,
|
||||
gender: user.gender,
|
||||
bahamut: bahamutMode,
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
if (props.onOpenChange) props.onOpenChange(false)
|
||||
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))
|
||||
.map((item, i) => {
|
||||
return (
|
||||
<SelectItem
|
||||
<PictureSelectItem
|
||||
key={`picture-${i}`}
|
||||
element={item.element}
|
||||
icon={{
|
||||
alt: item.name[locale],
|
||||
src: [
|
||||
`/profile/${item.filename}.png`,
|
||||
`/profile/${item.filename}@2x.png 2x`,
|
||||
],
|
||||
}}
|
||||
src={[
|
||||
`/profile/${item.filename}.png`,
|
||||
`/profile/${item.filename}@2x.png 2x`,
|
||||
]}
|
||||
value={item.filename}
|
||||
>
|
||||
{item.name[locale]}
|
||||
</SelectItem>
|
||||
</PictureSelectItem>
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -196,17 +215,15 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
|||
<SelectTableField
|
||||
name="picture"
|
||||
description={t('modals.settings.descriptions.picture')}
|
||||
className="image"
|
||||
className="Image"
|
||||
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}
|
||||
onOpenChange={() => openSelect('picture')}
|
||||
onChange={handlePictureChange}
|
||||
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}
|
||||
>
|
||||
{pictureOptions}
|
||||
|
|
@ -274,15 +291,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
|||
</SelectTableField>
|
||||
)
|
||||
|
||||
const adminField = () => (
|
||||
<SwitchTableField
|
||||
name="admin"
|
||||
label={t('modals.settings.labels.admin')}
|
||||
value={props.bahamutMode}
|
||||
onValueChange={(value: boolean) => setBahamutMode(value)}
|
||||
/>
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
|
@ -295,35 +303,38 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
|
|||
<Dialog open={open} onOpenChange={openChange}>
|
||||
<DialogContent
|
||||
className="Account"
|
||||
headerRef={headerRef}
|
||||
footerRef={footerRef}
|
||||
headerref={headerRef}
|
||||
footerref={footerRef}
|
||||
onOpenAutoFocus={(event: Event) => {}}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
>
|
||||
<DialogHeader
|
||||
title={`@${username}`}
|
||||
subtitle={t('modals.settings.title')}
|
||||
/>
|
||||
<div className="DialogHeader" ref={headerRef}>
|
||||
<div className="DialogTop">
|
||||
<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}>
|
||||
<div className={styles.fields}>
|
||||
<div className="Fields">
|
||||
{pictureField()}
|
||||
{genderField()}
|
||||
{languageField()}
|
||||
{themeField()}
|
||||
{props.role === 9 && adminField()}
|
||||
</div>
|
||||
|
||||
<DialogFooter
|
||||
ref={footerRef}
|
||||
rightElements={[
|
||||
<Button
|
||||
bound={true}
|
||||
key="confirm"
|
||||
text={t('modals.settings.buttons.confirm')}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
<div className="DialogFooter" ref={footerRef}>
|
||||
<Button
|
||||
contained={true}
|
||||
text={t('modals.settings.buttons.confirm')}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</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;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
@ -7,20 +7,10 @@
|
|||
width: 100vw;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 12;
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
isolation: isolate;
|
||||
position: fixed;
|
||||
z-index: 9;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.alert {
|
||||
.Alert {
|
||||
animation: $duration-modal-open cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none
|
||||
running openModalDesktop;
|
||||
background: var(--dialog-bg);
|
||||
|
|
@ -29,23 +19,17 @@
|
|||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
min-width: 20vw;
|
||||
max-width: 40vw;
|
||||
max-width: 30vw;
|
||||
padding: $unit * 4;
|
||||
|
||||
@include breakpoint(tablet) {
|
||||
max-width: inherit;
|
||||
max-width: 60vw;
|
||||
}
|
||||
|
||||
@include breakpoint(phone) {
|
||||
max-width: inherit;
|
||||
width: 70vw;
|
||||
width: 60vw;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $font-regular;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
|
||||
strong {
|
||||
font-weight: $bold;
|
||||
|
|
@ -56,27 +40,5 @@
|
|||
display: flex;
|
||||
align-self: flex-end;
|
||||
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 * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
import Button from '~components/common/Button'
|
||||
import Overlay from '~components/common/Overlay'
|
||||
import './index.scss'
|
||||
import Button from '~components/Button'
|
||||
import Overlay from '~components/Overlay'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
|
|
@ -12,7 +12,6 @@ interface Props {
|
|||
message: string | React.ReactNode
|
||||
primaryAction?: () => void
|
||||
primaryActionText?: string
|
||||
primaryActionClassName?: string
|
||||
cancelAction: () => void
|
||||
cancelActionText: string
|
||||
}
|
||||
|
|
@ -21,44 +20,40 @@ const Alert = (props: Props) => {
|
|||
return (
|
||||
<AlertDialog.Root open={props.open}>
|
||||
<AlertDialog.Portal>
|
||||
<Overlay
|
||||
className="alert"
|
||||
open={props.open}
|
||||
visible={true}
|
||||
onClick={props.cancelAction}
|
||||
/>
|
||||
<div className={styles.wrapper}>
|
||||
<AlertDialog.Content
|
||||
className={styles.alert}
|
||||
onEscapeKeyDown={props.cancelAction}
|
||||
>
|
||||
{props.title && (
|
||||
<AlertDialog.Overlay className="Overlay" onClick={props.cancelAction} />
|
||||
<div className="AlertWrapper">
|
||||
<AlertDialog.Content className="Alert">
|
||||
{props.title ? (
|
||||
<AlertDialog.Title>{props.title}</AlertDialog.Title>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<AlertDialog.Description className={styles.description}>
|
||||
<AlertDialog.Description className="description">
|
||||
{props.message}
|
||||
</AlertDialog.Description>
|
||||
<div className={styles.buttons}>
|
||||
<div className="buttons">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<Button
|
||||
bound={true}
|
||||
contained={true}
|
||||
onClick={props.cancelAction}
|
||||
text={props.cancelActionText}
|
||||
/>
|
||||
</AlertDialog.Cancel>
|
||||
{props.primaryAction && (
|
||||
{props.primaryAction ? (
|
||||
<AlertDialog.Action asChild>
|
||||
<Button
|
||||
className={props.primaryActionClassName}
|
||||
bound={true}
|
||||
contained={true}
|
||||
onClick={props.primaryAction}
|
||||
text={props.primaryActionText}
|
||||
/>
|
||||
</AlertDialog.Action>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</div>
|
||||
<Overlay open={props.open} visible={true} />
|
||||
</AlertDialog.Portal>
|
||||
</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, { useEffect, useState } from 'react'
|
||||
import { getCookie } from 'cookies-next'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import React, { ForwardedRef, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
|
||||
import Input from '~components/common/Input'
|
||||
import Select from '~components/common/Select'
|
||||
import SelectItem from '~components/common/SelectItem'
|
||||
import Select from '~components/Select'
|
||||
import SelectItem from '~components/SelectItem'
|
||||
|
||||
import classNames from 'classnames'
|
||||
|
||||
import ax from '~data/ax'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
import './index.scss'
|
||||
|
||||
interface ErrorMap {
|
||||
[index: string]: string
|
||||
|
|
@ -33,8 +31,10 @@ interface Props {
|
|||
}
|
||||
|
||||
const AXSelect = (props: Props) => {
|
||||
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
|
||||
const t = useTranslations('common')
|
||||
const router = useRouter()
|
||||
const locale =
|
||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
const [openAX1, setOpenAX1] = useState(false)
|
||||
const [openAX2, setOpenAX2] = useState(false)
|
||||
|
|
@ -45,19 +45,14 @@ const AXSelect = (props: Props) => {
|
|||
axValue2: '',
|
||||
})
|
||||
|
||||
const inputClasses = classNames({
|
||||
fullHeight: true,
|
||||
range: true,
|
||||
})
|
||||
|
||||
const primaryErrorClasses = classNames({
|
||||
[styles.errors]: true,
|
||||
[styles.visible]: errors.axValue1.length > 0,
|
||||
errors: true,
|
||||
visible: errors.axValue1.length > 0,
|
||||
})
|
||||
|
||||
const secondaryErrorClasses = classNames({
|
||||
[styles.errors]: true,
|
||||
[styles.visible]: errors.axValue2.length > 0,
|
||||
errors: true,
|
||||
visible: errors.axValue2.length > 0,
|
||||
})
|
||||
|
||||
// Refs
|
||||
|
|
@ -69,12 +64,8 @@ const AXSelect = (props: Props) => {
|
|||
// States
|
||||
const [primaryAxModifier, setPrimaryAxModifier] = useState(-1)
|
||||
const [secondaryAxModifier, setSecondaryAxModifier] = useState(-1)
|
||||
const [primaryAxValue, setPrimaryAxValue] = useState(
|
||||
props.currentSkills ? props.currentSkills[0].strength : 0.0
|
||||
)
|
||||
const [secondaryAxValue, setSecondaryAxValue] = useState(
|
||||
props.currentSkills ? props.currentSkills[1].strength : 0.0
|
||||
)
|
||||
const [primaryAxValue, setPrimaryAxValue] = useState(0.0)
|
||||
const [secondaryAxValue, setSecondaryAxValue] = useState(0.0)
|
||||
|
||||
useEffect(() => {
|
||||
setupAx1()
|
||||
|
|
@ -148,11 +139,8 @@ const AXSelect = (props: Props) => {
|
|||
|
||||
// Classes
|
||||
const secondarySetClasses = classNames({
|
||||
[styles.set]: true,
|
||||
[styles.hidden]:
|
||||
primaryAxModifier < 0 ||
|
||||
primaryAxModifier === 18 ||
|
||||
primaryAxModifier === 19,
|
||||
AXSet: true,
|
||||
hidden: primaryAxModifier < 0,
|
||||
})
|
||||
|
||||
function setupAx1() {
|
||||
|
|
@ -276,17 +264,14 @@ const AXSelect = (props: Props) => {
|
|||
secondaryAxModifierSelect.current &&
|
||||
secondaryAxValueInput.current
|
||||
) {
|
||||
setupInput(
|
||||
ax[props.axType - 1].find((ax) => ax.id === value),
|
||||
primaryAxValueInput.current
|
||||
)
|
||||
|
||||
setupInput(ax[props.axType - 1][value], primaryAxValueInput.current)
|
||||
setPrimaryAxValue(0)
|
||||
primaryAxValueInput.current.value = ''
|
||||
|
||||
// Reset the secondary AX modifier, reset the AX value and hide the input
|
||||
setSecondaryAxModifier(-1)
|
||||
setSecondaryAxValue(0)
|
||||
// secondaryAxValueInput.current.className = 'Input Contained'
|
||||
secondaryAxValueInput.current.className = 'Input Contained'
|
||||
secondaryAxValueInput.current.value = ''
|
||||
}
|
||||
}
|
||||
|
|
@ -311,7 +296,7 @@ const AXSelect = (props: Props) => {
|
|||
const value = parseFloat(event.target.value)
|
||||
let newErrors = { ...errors }
|
||||
|
||||
if (primaryAxValueInput.current === event.target) {
|
||||
if (primaryAxValueInput.current == event.target) {
|
||||
if (handlePrimaryErrors(value)) setPrimaryAxValue(value)
|
||||
} else {
|
||||
if (handleSecondaryErrors(value)) setSecondaryAxValue(value)
|
||||
|
|
@ -319,18 +304,16 @@ const AXSelect = (props: Props) => {
|
|||
}
|
||||
|
||||
function handlePrimaryErrors(value: number) {
|
||||
const primaryAxSkill = ax[props.axType - 1].find(
|
||||
(ax) => ax.id === primaryAxModifier
|
||||
)
|
||||
const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
|
||||
let newErrors = { ...errors }
|
||||
|
||||
if (primaryAxSkill && value < primaryAxSkill.minValue) {
|
||||
if (value < primaryAxSkill.minValue) {
|
||||
newErrors.axValue1 = t('ax.errors.value_too_low', {
|
||||
name: primaryAxSkill.name[locale],
|
||||
minValue: primaryAxSkill.minValue,
|
||||
suffix: primaryAxSkill.suffix ? primaryAxSkill.suffix : '',
|
||||
})
|
||||
} else if (primaryAxSkill && value > primaryAxSkill.maxValue) {
|
||||
} else if (value > primaryAxSkill.maxValue) {
|
||||
newErrors.axValue1 = t('ax.errors.value_too_high', {
|
||||
name: primaryAxSkill.name[locale],
|
||||
maxValue: primaryAxSkill.maxValue,
|
||||
|
|
@ -338,7 +321,7 @@ const AXSelect = (props: Props) => {
|
|||
})
|
||||
} else if (!value || value <= 0) {
|
||||
newErrors.axValue1 = t('ax.errors.value_empty', {
|
||||
name: primaryAxSkill?.name[locale] || '',
|
||||
name: primaryAxSkill.name[locale],
|
||||
})
|
||||
} else {
|
||||
newErrors.axValue1 = ''
|
||||
|
|
@ -391,11 +374,10 @@ const AXSelect = (props: Props) => {
|
|||
}
|
||||
|
||||
function setupInput(ax: ItemSkill | undefined, element: HTMLInputElement) {
|
||||
console.log(ax)
|
||||
if (ax) {
|
||||
const rangeString = `${ax.minValue}~${ax.maxValue}${ax.suffix || ''}`
|
||||
|
||||
// element.className = 'Input Bound Visible'
|
||||
element.className = 'Input Bound Visible'
|
||||
element.disabled = false
|
||||
element.placeholder = rangeString
|
||||
element.min = `${ax.minValue}`
|
||||
|
|
@ -404,12 +386,12 @@ const AXSelect = (props: Props) => {
|
|||
} else {
|
||||
if (primaryAxValueInput.current && secondaryAxValueInput.current) {
|
||||
if (primaryAxValueInput.current == element) {
|
||||
// primaryAxValueInput.current.className = 'Input Contained'
|
||||
primaryAxValueInput.current.className = 'Input Contained'
|
||||
primaryAxValueInput.current.disabled = true
|
||||
primaryAxValueInput.current.placeholder = ''
|
||||
}
|
||||
|
||||
// secondaryAxValueInput.current.className = 'Input Contained'
|
||||
secondaryAxValueInput.current.className = 'Input Contained'
|
||||
secondaryAxValueInput.current.disabled = true
|
||||
secondaryAxValueInput.current.placeholder = ''
|
||||
}
|
||||
|
|
@ -417,33 +399,28 @@ const AXSelect = (props: Props) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.ax}>
|
||||
<div className={styles.set}>
|
||||
<div className={styles.fields}>
|
||||
<div className="AXSelect">
|
||||
<div className="AXSet">
|
||||
<div className="fields">
|
||||
<Select
|
||||
key="ax1"
|
||||
value={`${primaryAxModifier}`}
|
||||
open={openAX1}
|
||||
trigger={{
|
||||
bound: true,
|
||||
className: 'grow',
|
||||
}}
|
||||
onClose={() => onClose(1)}
|
||||
onOpenChange={() => openSelect(1)}
|
||||
onValueChange={handleAX1SelectChange}
|
||||
triggerClass="modal"
|
||||
overlayVisible={false}
|
||||
>
|
||||
{generateOptions(0)}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
className={inputClasses}
|
||||
wrapperClassName="fullHeight"
|
||||
fieldsetClassName={classNames({
|
||||
hidden: primaryAxModifier < 0,
|
||||
})}
|
||||
bound={true}
|
||||
value={primaryAxValue}
|
||||
<input
|
||||
defaultValue={
|
||||
props.currentSkills && props.currentSkills[0]
|
||||
? props.currentSkills[0].strength
|
||||
: 0
|
||||
}
|
||||
type="number"
|
||||
onChange={handleInputChange}
|
||||
ref={primaryAxValueInput}
|
||||
|
|
@ -453,31 +430,26 @@ const AXSelect = (props: Props) => {
|
|||
</div>
|
||||
|
||||
<div className={secondarySetClasses}>
|
||||
<div className={styles.fields}>
|
||||
<div className="fields">
|
||||
<Select
|
||||
key="ax2"
|
||||
value={`${secondaryAxModifier}`}
|
||||
open={openAX2}
|
||||
trigger={{
|
||||
bound: true,
|
||||
className: 'grow',
|
||||
}}
|
||||
onClose={() => onClose(2)}
|
||||
onOpenChange={() => openSelect(2)}
|
||||
onValueChange={handleAX2SelectChange}
|
||||
triggerClass="modal"
|
||||
ref={secondaryAxModifierSelect}
|
||||
overlayVisible={false}
|
||||
>
|
||||
{generateOptions(1)}
|
||||
</Select>
|
||||
<Input
|
||||
className={inputClasses}
|
||||
wrapperClassName="fullHeight"
|
||||
fieldsetClassName={classNames({
|
||||
hidden: secondaryAxModifier < 0,
|
||||
})}
|
||||
bound={true}
|
||||
value={secondaryAxValue}
|
||||
<input
|
||||
defaultValue={
|
||||
props.currentSkills && props.currentSkills[1]
|
||||
? props.currentSkills[1].strength
|
||||
: 0
|
||||
}
|
||||
type="number"
|
||||
onChange={handleInputChange}
|
||||
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 classNames from 'classnames'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
import './index.scss'
|
||||
|
||||
interface Props
|
||||
extends React.DetailedHTMLProps<
|
||||
|
|
@ -14,18 +14,16 @@ interface Props
|
|||
rightAccessoryClassName?: string
|
||||
active?: boolean
|
||||
blended?: boolean
|
||||
bound?: boolean
|
||||
floating?: boolean
|
||||
size?: 'icon' | 'small' | 'medium' | 'large'
|
||||
contained?: boolean
|
||||
buttonSize?: 'small' | 'medium' | 'large'
|
||||
text?: string
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
active: false,
|
||||
blended: false,
|
||||
bound: false,
|
||||
floating: false,
|
||||
size: 'medium' as const,
|
||||
contained: false,
|
||||
buttonSize: 'medium' as const,
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
||||
|
|
@ -36,44 +34,29 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
|||
rightAccessoryClassName,
|
||||
active,
|
||||
blended,
|
||||
floating,
|
||||
bound,
|
||||
size,
|
||||
contained,
|
||||
buttonSize,
|
||||
text,
|
||||
...props
|
||||
},
|
||||
forwardedRef
|
||||
) {
|
||||
const classes = classNames(
|
||||
{
|
||||
[styles.button]: true,
|
||||
[styles.active]: active,
|
||||
[styles.bound]: bound,
|
||||
[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 classes = classNames(buttonSize, props.className, {
|
||||
Button: true,
|
||||
Active: active,
|
||||
Blended: blended,
|
||||
Contained: contained,
|
||||
})
|
||||
|
||||
const leftAccessoryClasses = classNames(
|
||||
{
|
||||
[styles.accessory]: true,
|
||||
[styles.left]: true,
|
||||
},
|
||||
leftAccessoryClassName?.split(' ').map((className) => styles[className])
|
||||
)
|
||||
const leftAccessoryClasses = classNames(leftAccessoryClassName, {
|
||||
Accessory: true,
|
||||
Left: true,
|
||||
})
|
||||
|
||||
const rightAccessoryClasses = classNames(
|
||||
{
|
||||
[styles.accessory]: true,
|
||||
[styles.right]: true,
|
||||
},
|
||||
rightAccessoryClassName?.split(' ').map((className) => styles[className])
|
||||
)
|
||||
const rightAccessoryClasses = classNames(rightAccessoryClassName, {
|
||||
Accessory: true,
|
||||
Right: true,
|
||||
})
|
||||
|
||||
const hasLeftAccessory = () => {
|
||||
if (leftAccessoryIcon)
|
||||
|
|
@ -86,7 +69,7 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
|
|||
}
|
||||
|
||||
const hasText = () => {
|
||||
if (text) return <span className={styles.text}>{text}</span>
|
||||
if (text) return <span className="Text">{text}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
.unit {
|
||||
.ChangelogUnit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
||||
import { getCookie } from 'cookies-next'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Trans, useTranslation } from 'next-i18next'
|
||||
|
||||
import { Dialog } from '~components/common/Dialog'
|
||||
import DialogContent from '~components/common/DialogContent'
|
||||
import DialogFooter from '~components/common/DialogFooter'
|
||||
import Button from '~components/common/Button'
|
||||
import Overlay from '~components/common/Overlay'
|
||||
import { Dialog } from '~components/Dialog'
|
||||
import DialogContent from '~components/DialogContent'
|
||||
import Button from '~components/Button'
|
||||
import Overlay from '~components/Overlay'
|
||||
|
||||
import { appState } from '~utils/appState'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
import './index.scss'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
|
|
@ -27,12 +23,9 @@ interface Props {
|
|||
const CharacterConflictModal = (props: Props) => {
|
||||
// Localization
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const t = useTranslations('common')
|
||||
const routerLocale = getCookie('NEXT_LOCALE')
|
||||
const { t } = useTranslation('common')
|
||||
const locale =
|
||||
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en'
|
||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||
|
||||
// States
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -66,7 +59,7 @@ const CharacterConflictModal = (props: Props) => {
|
|||
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) {
|
||||
|
|
@ -82,21 +75,19 @@ const CharacterConflictModal = (props: Props) => {
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={openChange}>
|
||||
<DialogContent
|
||||
className="conflict"
|
||||
footerRef={footerRef}
|
||||
className="Conflict"
|
||||
footerref={footerRef}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onEscapeKeyDown={close}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<div className="Content">
|
||||
<p>
|
||||
{t.rich('modals.conflict.character', {
|
||||
strong: (chunks) => <strong>{chunks}</strong>
|
||||
})}
|
||||
<Trans i18nKey="modals.conflict.character"></Trans>
|
||||
</p>
|
||||
<div className={styles.diagram}>
|
||||
<div className="CharacterDiagram Diagram">
|
||||
<ul>
|
||||
{props.conflictingCharacters?.map((character, i) => (
|
||||
<li className={styles.character} key={`conflict-${i}`}>
|
||||
<li className="character" key={`conflict-${i}`}>
|
||||
<img
|
||||
alt={character.object.name[locale]}
|
||||
src={imageUrl(character.object, character.uncap_level)}
|
||||
|
|
@ -105,9 +96,9 @@ const CharacterConflictModal = (props: Props) => {
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span className={styles.arrow}>→</span>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.character}>
|
||||
<span className="arrow">→</span>
|
||||
<div className="wrapper">
|
||||
<div className="character">
|
||||
<img
|
||||
alt={props.incomingCharacter?.name[locale]}
|
||||
src={imageUrl(props.incomingCharacter)}
|
||||
|
|
@ -117,22 +108,20 @@ const CharacterConflictModal = (props: Props) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter
|
||||
rightElements={[
|
||||
<div className="DialogFooter" ref={footerRef}>
|
||||
<div className="Buttons Span">
|
||||
<Button
|
||||
bound={true}
|
||||
contained={true}
|
||||
onClick={close}
|
||||
key="cancel"
|
||||
text={t('buttons.cancel')}
|
||||
/>,
|
||||
/>
|
||||
<Button
|
||||
bound={true}
|
||||
contained={true}
|
||||
onClick={props.resolveConflict}
|
||||
key="confirm"
|
||||
text={t('modals.conflict.buttons.confirm')}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<Overlay open={open} visible={true} />
|
||||
</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 { getCookie } from 'cookies-next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
|
||||
import { AxiosError, AxiosResponse } from 'axios'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import Alert from '~components/common/Alert'
|
||||
import JobSection from '~components/job/JobSection'
|
||||
import CharacterUnit from '~components/character/CharacterUnit'
|
||||
import CharacterConflictModal from '~components/character/CharacterConflictModal'
|
||||
import Alert from '~components/Alert'
|
||||
import JobSection from '~components/JobSection'
|
||||
import CharacterUnit from '~components/CharacterUnit'
|
||||
import CharacterConflictModal from '~components/CharacterConflictModal'
|
||||
|
||||
import type { DetailsObject, JobSkillObject, SearchableObject } from '~types'
|
||||
|
||||
import api from '~utils/api'
|
||||
import { appState } from '~utils/appState'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
import './index.scss'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
|
|
@ -33,7 +33,7 @@ const CharacterGrid = (props: Props) => {
|
|||
const numCharacters: number = 5
|
||||
|
||||
// Localization
|
||||
const t = useTranslations('common')
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
// Cookies
|
||||
const cookie = getCookie('account')
|
||||
|
|
@ -100,19 +100,13 @@ const CharacterGrid = (props: Props) => {
|
|||
if (!party.id) {
|
||||
props.createParty().then((team) => {
|
||||
saveCharacter(team.id, character, position)
|
||||
.then((response) => {
|
||||
const data = response.data['grid_character']
|
||||
storeGridCharacter(data)
|
||||
})
|
||||
.then((response) => storeGridCharacter(response.data))
|
||||
.catch((error) => console.error(error))
|
||||
})
|
||||
} else {
|
||||
if (props.editable)
|
||||
saveCharacter(party.id, character, position)
|
||||
.then((response) => {
|
||||
const data = response.data['grid_character']
|
||||
handleCharacterResponse(data)
|
||||
})
|
||||
.then((response) => handleCharacterResponse(response.data))
|
||||
.catch((error) => {
|
||||
const axiosError = error as AxiosError
|
||||
const response = axiosError.response
|
||||
|
|
@ -132,7 +126,6 @@ const CharacterGrid = (props: Props) => {
|
|||
setPosition(data.position)
|
||||
setModalOpen(true)
|
||||
} else {
|
||||
console.log(data)
|
||||
storeGridCharacter(data)
|
||||
}
|
||||
}
|
||||
|
|
@ -187,7 +180,6 @@ const CharacterGrid = (props: Props) => {
|
|||
setPosition(-1)
|
||||
setConflicts([])
|
||||
setIncoming(undefined)
|
||||
setModalOpen(false)
|
||||
}
|
||||
|
||||
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) {
|
||||
const payload = {
|
||||
party: {
|
||||
|
|
@ -508,9 +483,7 @@ const CharacterGrid = (props: Props) => {
|
|||
<Alert
|
||||
open={errorAlertOpen}
|
||||
title={axiosError ? `${axiosError.status}` : 'Error'}
|
||||
message={axiosError?.statusText && axiosError.statusText !== 'undefined'
|
||||
? t(`errors.${axiosError.statusText.toLowerCase()}`)
|
||||
: t('errors.internal_server_error.description')}
|
||||
message={t(`errors.${axiosError?.statusText.toLowerCase()}`)}
|
||||
cancelAction={() => setErrorAlertOpen(false)}
|
||||
cancelActionText={t('buttons.confirm')}
|
||||
/>
|
||||
|
|
@ -525,7 +498,7 @@ const CharacterGrid = (props: Props) => {
|
|||
cancelAction={cancelAlert}
|
||||
cancelActionText={'Got it'}
|
||||
/>
|
||||
<div className={styles.grid}>
|
||||
<div id="CharacterGrid">
|
||||
<JobSection
|
||||
job={job}
|
||||
jobSkills={jobSkills}
|
||||
|
|
@ -533,7 +506,6 @@ const CharacterGrid = (props: Props) => {
|
|||
editable={props.editable}
|
||||
saveJob={saveJob}
|
||||
saveSkill={saveJobSkill}
|
||||
removeSkill={removeJobSkill}
|
||||
saveAccessory={saveAccessory}
|
||||
/>
|
||||
<CharacterConflictModal
|
||||
|
|
@ -544,7 +516,7 @@ const CharacterGrid = (props: Props) => {
|
|||
resolveConflict={resolveConflict}
|
||||
resetConflict={resetConflict}
|
||||
/>
|
||||
<ul className={styles.characters}>
|
||||
<ul id="Characters">
|
||||
{Array.from(Array(numCharacters)).map((x, i) => {
|
||||
return (
|
||||
<li key={`grid_unit_${i}`}>
|
||||
|
|
@ -1,5 +1,20 @@
|
|||
.content {
|
||||
.mastery {
|
||||
.Character.HovercardContent {
|
||||
.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;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
|
|
@ -9,7 +24,7 @@
|
|||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
|
||||
.extendedMastery {
|
||||
.ExtendedMastery {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
|
|
@ -25,7 +40,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.awakening {
|
||||
.Awakening {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
|
|
@ -44,4 +59,10 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .Footer {
|
||||
// position: sticky;
|
||||
// bottom: 0;
|
||||
// left: 0;
|
||||
// }
|
||||
}
|
||||
|
|
@ -1,28 +1,25 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
||||
import { getCookie } from 'cookies-next'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
|
||||
import {
|
||||
Hovercard,
|
||||
HovercardContent,
|
||||
HovercardTrigger,
|
||||
} from '~components/common/Hovercard'
|
||||
import Button from '~components/common/Button'
|
||||
import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon'
|
||||
import UncapIndicator from '~components/uncap/UncapIndicator'
|
||||
} from '~components/Hovercard'
|
||||
import Button from '~components/Button'
|
||||
import WeaponLabelIcon from '~components/WeaponLabelIcon'
|
||||
import UncapIndicator from '~components/UncapIndicator'
|
||||
|
||||
import {
|
||||
overMastery,
|
||||
aetherialMastery,
|
||||
permanentMastery,
|
||||
} from '~data/overMastery'
|
||||
import { characterAwakening } from '~data/awakening'
|
||||
import { ExtendedMastery } from '~types'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
import HovercardHeader from '~components/HovercardHeader'
|
||||
import './index.scss'
|
||||
|
||||
interface Props {
|
||||
gridCharacter: GridCharacter
|
||||
|
|
@ -30,16 +27,34 @@ interface Props {
|
|||
onTriggerClick: () => void
|
||||
}
|
||||
|
||||
interface KeyNames {
|
||||
[key: string]: {
|
||||
en: string
|
||||
jp: string
|
||||
}
|
||||
}
|
||||
|
||||
const CharacterHovercard = (props: Props) => {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const t = useTranslations('common')
|
||||
const routerLocale = getCookie('NEXT_LOCALE')
|
||||
const { t } = useTranslation('common')
|
||||
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 Proficiency = [
|
||||
'none',
|
||||
'sword',
|
||||
'dagger',
|
||||
'axe',
|
||||
'spear',
|
||||
'bow',
|
||||
'staff',
|
||||
'fist',
|
||||
'harp',
|
||||
'gun',
|
||||
'katana',
|
||||
]
|
||||
|
||||
const tintElement = Element[props.gridCharacter.object.element]
|
||||
|
||||
function goTo() {
|
||||
|
|
@ -49,6 +64,30 @@ const CharacterHovercard = (props: Props) => {
|
|||
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) {
|
||||
const canonicalMastery = dictionary.find(
|
||||
(item) => item.id === mastery.modifier
|
||||
|
|
@ -56,7 +95,7 @@ const CharacterHovercard = (props: Props) => {
|
|||
|
||||
if (canonicalMastery) {
|
||||
return (
|
||||
<li className={styles.extendedMastery} key={canonicalMastery.id}>
|
||||
<li className="ExtendedMastery" key={canonicalMastery.id}>
|
||||
<img
|
||||
alt={canonicalMastery.name[locale]}
|
||||
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/mastery/${canonicalMastery.slug}.png`}
|
||||
|
|
@ -71,25 +110,21 @@ const CharacterHovercard = (props: Props) => {
|
|||
}
|
||||
|
||||
const overMasterySection = () => {
|
||||
if (
|
||||
props.gridCharacter &&
|
||||
props.gridCharacter.over_mastery &&
|
||||
props.gridCharacter.over_mastery.length > 0
|
||||
) {
|
||||
if (props.gridCharacter && props.gridCharacter.over_mastery) {
|
||||
return (
|
||||
<section className={styles.mastery}>
|
||||
<section className="Mastery">
|
||||
<h5 className={tintElement}>
|
||||
{t('modals.characters.subtitles.ring')}
|
||||
</h5>
|
||||
<ul>
|
||||
{[...Array(4)].map((e, i) => {
|
||||
const ringIndex = i + 1
|
||||
const ringStat: ExtendedMastery =
|
||||
props.gridCharacter.over_mastery[i]
|
||||
|
||||
if (ringStat && ringStat.modifier && ringStat.modifier > 0) {
|
||||
if (i === 0 || i === 1) {
|
||||
if (ringIndex === 1 || ringIndex === 2) {
|
||||
return masteryElement(overMastery.a, ringStat)
|
||||
} else if (i === 2) {
|
||||
} else if (ringIndex === 3) {
|
||||
return masteryElement(overMastery.b, ringStat)
|
||||
} else {
|
||||
return masteryElement(overMastery.c, ringStat)
|
||||
|
|
@ -105,12 +140,11 @@ const CharacterHovercard = (props: Props) => {
|
|||
const aetherialMasterySection = () => {
|
||||
if (
|
||||
props.gridCharacter &&
|
||||
props.gridCharacter.over_mastery &&
|
||||
props.gridCharacter.aetherial_mastery &&
|
||||
props.gridCharacter.aetherial_mastery?.modifier > 0
|
||||
props.gridCharacter.aetherial_mastery.modifier > 0
|
||||
) {
|
||||
return (
|
||||
<section className={styles.mastery}>
|
||||
<section className="Mastery">
|
||||
<h5 className={tintElement}>
|
||||
{t('modals.characters.subtitles.earring')}
|
||||
</h5>
|
||||
|
|
@ -128,7 +162,7 @@ const CharacterHovercard = (props: Props) => {
|
|||
const permanentMasterySection = () => {
|
||||
if (props.gridCharacter && props.gridCharacter.perpetuity) {
|
||||
return (
|
||||
<section className={styles.mastery}>
|
||||
<section className="Mastery">
|
||||
<h5 className={tintElement}>
|
||||
{t('modals.characters.subtitles.permanent')}
|
||||
</h5>
|
||||
|
|
@ -146,22 +180,28 @@ const CharacterHovercard = (props: Props) => {
|
|||
}
|
||||
|
||||
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 (
|
||||
<section className={styles.awakening}>
|
||||
<section className="Awakening">
|
||||
<h5 className={tintElement}>
|
||||
{t('modals.characters.subtitles.awakening')}
|
||||
</h5>
|
||||
<div>
|
||||
{gridAwakening.type.slug !== 'character-balanced' && (
|
||||
{gridAwakening.type > 1 ? (
|
||||
<img
|
||||
alt={gridAwakening.type.name[locale]}
|
||||
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/awakening/${gridAwakening.type.slug}.jpg`}
|
||||
alt={awakening.name[locale]}
|
||||
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/awakening/character_${gridAwakening.type}.jpg`}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<span>
|
||||
<strong>{`${gridAwakening.type.name[locale]}`}</strong>
|
||||
<strong>{`${awakening.name[locale]}`}</strong>
|
||||
{`Lv${gridAwakening.level}`}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -175,7 +215,7 @@ const CharacterHovercard = (props: Props) => {
|
|||
className={tintElement}
|
||||
text={t('buttons.wiki')}
|
||||
onClick={goTo}
|
||||
bound={true}
|
||||
contained={true}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
@ -184,12 +224,51 @@ const CharacterHovercard = (props: Props) => {
|
|||
<HovercardTrigger asChild onClick={props.onTriggerClick}>
|
||||
{props.children}
|
||||
</HovercardTrigger>
|
||||
<HovercardContent className={styles.content} side="top">
|
||||
<HovercardHeader
|
||||
gridObject={props.gridCharacter}
|
||||
object={props.gridCharacter.object}
|
||||
type="character"
|
||||
/>
|
||||
<HovercardContent className="Character" side="top">
|
||||
<div className="top">
|
||||
<div className="title">
|
||||
<h4>{props.gridCharacter.object.name[locale]}</h4>
|
||||
<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}
|
||||
{awakeningSection()}
|
||||
{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;
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
background: var(--button-contained-bg);
|
||||
cursor: pointer;
|
||||
|
||||
.info h5 {
|
||||
.Info h5 {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
width: 120px;
|
||||
}
|
||||
|
||||
.info {
|
||||
.Info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
|
|
@ -32,7 +32,6 @@
|
|||
display: inline-block;
|
||||
font-size: $font-medium;
|
||||
font-weight: $medium;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.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 { useTranslations } from 'next-intl'
|
||||
import { useTranslation } from 'next-i18next'
|
||||
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
|
||||
import SearchFilter from '~components/search/SearchFilter'
|
||||
import SearchFilterCheckboxItem from '~components/search/SearchFilterCheckboxItem'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
|
||||
import SearchFilter from '~components/SearchFilter'
|
||||
import SearchFilterCheckboxItem from '~components/SearchFilterCheckboxItem'
|
||||
|
||||
import './index.scss'
|
||||
import {
|
||||
emptyElementState,
|
||||
emptyProficiencyState,
|
||||
|
|
@ -12,14 +16,12 @@ import {
|
|||
} from '~utils/emptyStates'
|
||||
import { elements, proficiencies, rarities } from '~utils/stateValues'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
|
||||
interface Props {
|
||||
sendFilters: (filters: { [key: string]: number[] }) => void
|
||||
}
|
||||
|
||||
const CharacterSearchFilterBar = (props: Props) => {
|
||||
const t = useTranslations('common')
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
const [rarityMenu, setRarityMenu] = useState(false)
|
||||
const [elementMenu, setElementMenu] = useState(false)
|
||||
|
|
@ -142,90 +144,121 @@ const CharacterSearchFilterBar = (props: Props) => {
|
|||
return (
|
||||
<SearchFilter
|
||||
label={`${t('filters.labels.proficiency')} ${proficiency}`}
|
||||
display="grid"
|
||||
numSelected={numSelected}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
{Array.from(Array(proficiencies.length)).map((x, i) => {
|
||||
const checked =
|
||||
proficiency == 1
|
||||
? proficiency1State[proficiencies[i]].checked
|
||||
: proficiency2State[proficiencies[i]].checked
|
||||
<DropdownMenu.Label className="Label">{`${t(
|
||||
'filters.labels.proficiency'
|
||||
)} ${proficiency}`}</DropdownMenu.Label>
|
||||
<section>
|
||||
<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 (
|
||||
<SearchFilterCheckboxItem
|
||||
key={proficiencies[i]}
|
||||
onCheckedChange={onCheckedChange}
|
||||
checked={checked}
|
||||
valueKey={proficiencies[i]}
|
||||
>
|
||||
{t(`proficiencies.${proficiencies[i]}`)}
|
||||
</SearchFilterCheckboxItem>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<SearchFilterCheckboxItem
|
||||
key={proficiencies[i]}
|
||||
onCheckedChange={onCheckedChange}
|
||||
checked={checked}
|
||||
valueKey={proficiencies[i]}
|
||||
>
|
||||
{t(`proficiencies.${proficiencies[i]}`)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.filterBar}>
|
||||
{rarityFilter}
|
||||
{elementFilter}
|
||||
<div className="SearchFilterBar">
|
||||
<SearchFilter
|
||||
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(2)}
|
||||
</div>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
.unit {
|
||||
.CharacterUnit {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
position: relative;
|
||||
margin-bottom: $unit * 4;
|
||||
|
||||
&.editable .image:hover {
|
||||
&.editable .CharacterImage:hover {
|
||||
border: $hover-stroke;
|
||||
box-shadow: $hover-shadow;
|
||||
cursor: pointer;
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
z-index: 2;
|
||||
}
|
||||
|
||||
.image {
|
||||
.CharacterImage {
|
||||
aspect-ratio: 131 / 273;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid rgba(0, 0, 0, 0);
|
||||
|
|
@ -92,17 +92,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
.CharacterName {
|
||||
@include breakpoint(phone) {
|
||||
font-size: $font-tiny;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .perpetuity.empty {
|
||||
&:hover .Perpetuity.Empty {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.perpetuity {
|
||||
.Perpetuity {
|
||||
position: absolute;
|
||||
background-image: url('/icons/perpetuity/filled.svg');
|
||||
background-size: $unit-4x $unit-4x;
|
||||
|
|
@ -118,7 +118,7 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.empty {
|
||||
&.Empty {
|
||||
background-image: url('/icons/perpetuity/empty.svg');
|
||||
opacity: 0;
|
||||
|
||||
|
|
@ -1,26 +1,22 @@
|
|||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
||||
import { getCookie } from 'cookies-next'
|
||||
import React, { MouseEvent, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Trans, useTranslation } from 'next-i18next'
|
||||
import { AxiosResponse } from 'axios'
|
||||
import classNames from 'classnames'
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
|
||||
import Alert from '~components/common/Alert'
|
||||
import Button from '~components/common/Button'
|
||||
import CharacterHovercard from '~components/character/CharacterHovercard'
|
||||
import CharacterModal from '~components/character/CharacterModal'
|
||||
import Alert from '~components/Alert'
|
||||
import Button from '~components/Button'
|
||||
import CharacterHovercard from '~components/CharacterHovercard'
|
||||
import CharacterModal from '~components/CharacterModal'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
} from '~components/common/ContextMenu'
|
||||
import ContextMenuItem from '~components/common/ContextMenuItem'
|
||||
import SearchModal from '~components/search/SearchModal'
|
||||
import UncapIndicator from '~components/uncap/UncapIndicator'
|
||||
} from '~components/ContextMenu'
|
||||
import ContextMenuItem from '~components/ContextMenuItem'
|
||||
import SearchModal from '~components/SearchModal'
|
||||
import UncapIndicator from '~components/UncapIndicator'
|
||||
|
||||
import api from '~utils/api'
|
||||
import { appState } from '~utils/appState'
|
||||
|
|
@ -30,13 +26,12 @@ import SettingsIcon from '~public/icons/Settings.svg'
|
|||
|
||||
// Types
|
||||
import type {
|
||||
CharacterOverMastery,
|
||||
GridCharacterObject,
|
||||
PerpetuityObject,
|
||||
SearchableObject,
|
||||
} from '~types'
|
||||
|
||||
import styles from './index.module.scss'
|
||||
import './index.scss'
|
||||
|
||||
interface Props {
|
||||
gridCharacter?: GridCharacter
|
||||
|
|
@ -58,13 +53,10 @@ const CharacterUnit = ({
|
|||
updateTranscendence,
|
||||
}: Props) => {
|
||||
// Translations and locale
|
||||
const t = useTranslations('common')
|
||||
const { t } = useTranslation('common')
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const routerLocale = getCookie('NEXT_LOCALE')
|
||||
const locale =
|
||||
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en'
|
||||
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
|
||||
|
||||
// State snapshot
|
||||
const { party, grid } = useSnapshot(appState)
|
||||
|
|
@ -80,10 +72,14 @@ const CharacterUnit = ({
|
|||
|
||||
// Classes
|
||||
const classes = classNames({
|
||||
unit: true,
|
||||
[styles.unit]: true,
|
||||
[styles.editable]: editable,
|
||||
[styles.filled]: gridCharacter !== undefined,
|
||||
CharacterUnit: true,
|
||||
editable: editable,
|
||||
filled: gridCharacter !== undefined,
|
||||
})
|
||||
|
||||
const buttonClasses = classNames({
|
||||
Options: true,
|
||||
Clicked: contextMenuOpen,
|
||||
})
|
||||
|
||||
// Other
|
||||
|
|
@ -151,20 +147,7 @@ const CharacterUnit = ({
|
|||
// Save the server's response to state
|
||||
function processResult(response: AxiosResponse) {
|
||||
const gridCharacter: GridCharacter = response.data
|
||||
let character = cloneDeep(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
|
||||
appState.grid.characters[gridCharacter.position] = gridCharacter
|
||||
}
|
||||
|
||||
function processError(error: any) {
|
||||
|
|
@ -209,7 +192,7 @@ const CharacterUnit = ({
|
|||
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)
|
||||
|
|
@ -236,10 +219,8 @@ const CharacterUnit = ({
|
|||
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Button
|
||||
active={contextMenuOpen}
|
||||
floating={true}
|
||||
leftAccessoryIcon={<SettingsIcon />}
|
||||
className="options"
|
||||
className={buttonClasses}
|
||||
onClick={handleButtonClicked}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
|
|
@ -268,12 +249,11 @@ const CharacterUnit = ({
|
|||
cancelAction={() => setAlertOpen(false)}
|
||||
cancelActionText={t('buttons.cancel')}
|
||||
message={
|
||||
<>
|
||||
{t.rich('modals.characters.messages.remove', {
|
||||
character: gridCharacter?.object.name[locale] || '',
|
||||
strong: (chunks) => <strong>{chunks}</strong>
|
||||
})}
|
||||
</>
|
||||
<Trans i18nKey="modals.characters.messages.remove">
|
||||
Are you sure you want to remove{' '}
|
||||
<strong>{{ character: gridCharacter?.object.name[locale] }}</strong>{' '}
|
||||
from your team?
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
@ -298,8 +278,8 @@ const CharacterUnit = ({
|
|||
const perpetuity = () => {
|
||||
if (gridCharacter) {
|
||||
const classes = classNames({
|
||||
[styles.perpetuity]: true,
|
||||
[styles.empty]: !gridCharacter.perpetuity,
|
||||
Perpetuity: true,
|
||||
Empty: !gridCharacter.perpetuity,
|
||||
})
|
||||
|
||||
return <i className={classes} onClick={handlePerpetuityClick} />
|
||||
|
|
@ -317,13 +297,13 @@ const CharacterUnit = ({
|
|||
|
||||
const content = (
|
||||
<div
|
||||
className={styles.image}
|
||||
className="CharacterImage"
|
||||
tabIndex={gridCharacter ? gridCharacter.position * 7 : 0}
|
||||
onClick={openSearchModal}
|
||||
>
|
||||
{image}
|
||||
{editable ? (
|
||||
<span className={styles.icon}>
|
||||
<span className="icon">
|
||||
<PlusIcon />
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -366,7 +346,7 @@ const CharacterUnit = ({
|
|||
) : (
|
||||
''
|
||||
)}
|
||||
<h3 className={styles.name}>{character?.name[locale]}</h3>
|
||||
<h3 className="CharacterName">{character?.name[locale]}</h3>
|
||||
</div>
|
||||
{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);
|
||||
border-radius: $input-corner;
|
||||
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