Compare commits

..

10 commits

Author SHA1 Message Date
50a58dc47a Enable 13th weapon slot 2023-04-02 01:26:24 -07:00
5a41d503d0 Merge branch 'staging' of github.com:jedmund/hensei-web into staging 2023-04-02 01:19:17 -07:00
36408ede7e Missed items (#291)
* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

* Added content from the 2023/03/22 update (#287)

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items
2023-04-01 12:23:11 -07:00
d5b7fc584c Added items from 2023/03 Legfest and 2023/03/30 update (#290)
* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

* Added content from the 2023/03/22 update (#287)

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates
2023-04-01 12:23:11 -07:00
4c6830f049
Added World Series to weapon series empty state (#293)
* Push 2023/03 updates to main (#292)

* Added avatars

* Added content from the 2023/03/22 update (#287)

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added items from 2023/03 Legfest and 2023/03/30 update (#290)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

* Added content from the 2023/03/22 update (#287)

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

* Added content from the 2023/03/22 update (#287)

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Added items from 2023/03 Legfest and 2023/03/30 update (#290)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

* Added content from the 2023/03/22 update (#287)

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Missed items (#291)

* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

* Added content from the 2023/03/22 update (#287)

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items

* Add World series to empty state
2023-04-01 12:20:57 -07:00
d35cedaf04
Merge branch 'main' into staging 2023-03-31 12:03:37 -07:00
b53a261866
Missed items (#291)
* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

* Added content from the 2023/03/22 update (#287)

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates

* Add more items
2023-03-31 11:55:16 -07:00
ca0d14b5d6
Added items from 2023/03 Legfest and 2023/03/30 update (#290)
* Added avatars (#286)

* Deploy #287 (#288)

* Added avatars

* Added content from the 2023/03/22 update (#287)

* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art

* Added new weapon series

* Added updates
2023-03-31 11:41:04 -07:00
b1236a1f97
Added content from the 2023/03/22 update (#287)
* Added avatars (#286)

* Added localizations

* Added update, changed CSS

* Add logic for showing Lucifer uncap and 250 art
2023-03-21 17:45:41 -07:00
3690bcf2a5 Added avatars 2023-03-20 15:05:13 -07:00
595 changed files with 20795 additions and 50528 deletions

View file

@ -1,5 +0,0 @@
public/images
public/labels
public/profiles
tsconfig.tsbuildinfo
*.log

View file

@ -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

View file

@ -1,6 +1,7 @@
{ {
"extends": "next/core-web-vitals", "extends": "next/core-web-vitals",
"rules": { "rules": {
// Other rules
"@next/next/no-img-element": "off" "@next/next/no-img-element": "off"
} }
} }

11
.gitignore vendored
View file

@ -49,18 +49,13 @@ dist/
# Instructions will be provided to download these from the game # Instructions will be provided to download these from the game
public/images/weapon* public/images/weapon*
public/images/summon* public/images/summon*
public/images/character* public/images/chara*
public/images/job* public/images/job*
public/images/awakening* public/images/awakening*
public/images/ax* public/images/ax*
public/images/accessory* public/images/accessory*
public/images/mastery* public/images/mastery*
public/images/updates* public/images/updates*
public/images/guidebooks*
public/images/raids*
public/images/gacha*
public/images/previews*
public/image/profiles*
# Typescript v1 declaration files # Typescript v1 declaration files
typings/ typings/
@ -88,7 +83,3 @@ typings/
# DS_Store # DS_Store
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
codebase.md
# PRDs
prd/

View file

@ -1,2 +0,0 @@
[tools]
node = "20.12.0"

1
.nvmrc
View file

@ -1 +0,0 @@
20

View file

@ -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

View file

@ -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

View file

@ -1,5 +1,3 @@
{ {
"git.ignoreLimitWarning": true, "git.ignoreLimitWarning": true
"i18n-ally.localesPaths": ["public/locales"], }
"i18n-ally.keystyle": "nested"
}

View file

@ -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

View file

@ -54,24 +54,18 @@ root
├─ accessory-square/ ├─ accessory-square/
├─ awakening/ ├─ awakening/
├─ ax/ ├─ ax/
├─ character-main/ ├─ chara-main/
├─ character-grid/ ├─ chara-grid/
├─ character-square/ ├─ chara-square/
├─ guidebooks/
├─ jobs/ ├─ jobs/
├─ job-icons/ ├─ job-icons/
├─ job-portraits/
├─ job-skills/ ├─ job-skills/
├─ labels/
├─ mastery/ ├─ mastery/
├─ placeholders/
├─ raids/
├─ summon-main/ ├─ summon-main/
├─ summon-grid/ ├─ summon-grid/
├─ summon-square/ ├─ summon-square/
├─ updates/ ├─ updates/
├─ weapon-main/ ├─ weapon-main/
├─ weapon-grid/ ├─ weapon-grid/
├─ weapon-keys/
├─ weapon-square/ ├─ weapon-square/
``` ```

View file

@ -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

View file

@ -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()
}
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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&apos;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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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

View file

@ -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>
)
}
}

View file

@ -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>
)
}
}

View file

@ -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&apos;re looking for couldn&apos;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>
)
}

View file

@ -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

View file

@ -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()
}
}

View file

@ -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')
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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

View file

@ -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>
)
}
}

View file

@ -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>
)
}

View file

@ -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

View file

@ -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>
);
}
}

View file

@ -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&apos;t have permission to perform that action</p>
<div className="error-actions">
<Link href="/teams" className="button primary">
Browse teams
</Link>
</div>
</div>
</div>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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 }
)
}
}

View file

@ -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 }
)
}
}

View file

@ -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 }
)
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
)
}
}

View file

@ -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 }
)
}
}

View file

@ -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 }
)
}
}

View file

@ -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 }
)
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
)
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
)
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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

View file

@ -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>
)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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),
});

View file

@ -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;
}
}

View file

@ -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&apos;re looking for doesn&apos;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>
)
}

View file

@ -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);
}
}
}
}

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import Head from 'next/head' import Head from 'next/head'
import { useTranslations } from 'next-intl' import { useTranslation } from 'next-i18next'
interface Props { interface Props {
page: string page: string
@ -8,7 +8,7 @@ interface Props {
const AboutHead = ({ page }: Props) => { const AboutHead = ({ page }: Props) => {
// Import translations // Import translations
const t = useTranslations('common') const { t } = useTranslation('common')
// State // State
const [currentPage, setCurrentPage] = useState('about') const [currentPage, setCurrentPage] = useState('about')
@ -26,7 +26,7 @@ const AboutHead = ({ page }: Props) => {
name="description" name="description"
content={t(`page.descriptions.${currentPage}`)} content={t(`page.descriptions.${currentPage}`)}
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/images/favicon.png" /> <link rel="icon" type="image/x-icon" href="/images/favicon.png" />
{/* OpenGraph */} {/* OpenGraph */}

View file

@ -1,4 +1,4 @@
.about { .About.PageContent {
$width: 520px; $width: 520px;
padding-bottom: $unit-12x; padding-bottom: $unit-12x;
@ -9,7 +9,7 @@
gap: $unit-2x; gap: $unit-2x;
z-index: 5; z-index: 5;
.hero { .Hero {
position: absolute; position: absolute;
width: 40vw; width: 40vw;
height: 80vh; height: 80vh;
@ -55,10 +55,22 @@
z-index: 2; z-index: 2;
} }
} }
.Links {
.links {
display: grid; display: grid;
gap: $unit; gap: $unit;
margin: $unit-2x 0; margin: $unit-2x 0;
} }
div.LinkItem {
margin-top: $unit-2x;
}
.LinkItem {
max-width: calc($width / 3 * 2);
@include breakpoint(phone) {
max-width: inherit;
width: 100%;
}
}
} }

View 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&apos;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

View 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;
}
}
}

View file

@ -1,26 +1,37 @@
'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { getCookie, setCookie } from 'cookies-next' import { getCookie, setCookie } from 'cookies-next'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/router'
import { useTranslations } from 'next-intl' import { useTranslation } from 'next-i18next'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { Dialog } from '~components/common/Dialog' import {
import DialogHeader from '~components/common/DialogHeader' Dialog,
import DialogFooter from '~components/common/DialogFooter' DialogClose,
import DialogContent from '~components/common/DialogContent' DialogTitle,
import Button from '~components/common/Button' DialogTrigger,
import SelectItem from '~components/common/SelectItem' } from '~components/Dialog'
import SelectTableField from '~components/common/SelectTableField' import DialogContent from '~components/DialogContent'
import Button from '~components/Button'
import SelectItem from '~components/SelectItem'
import PictureSelectItem from '~components/PictureSelectItem'
import SelectTableField from '~components/SelectTableField'
// import * as Switch from '@radix-ui/react-switch'
import api from '~utils/api' import api from '~utils/api'
import changeLanguage from 'utils/changeLanguage' import changeLanguage from 'utils/changeLanguage'
import { accountState } from '~utils/accountState' import { accountState } from '~utils/accountState'
import { pictureData } from '~utils/pictureData' import { pictureData } from '~utils/pictureData'
import styles from './index.module.scss' import CrossIcon from '~public/icons/Cross.svg'
import SwitchTableField from '~components/common/SwitchTableField' import './index.scss'
type StateVariables = {
[key: string]: boolean
picture: boolean
gender: boolean
language: boolean
theme: boolean
}
interface Props { interface Props {
open: boolean open: boolean
@ -30,26 +41,40 @@ interface Props {
language?: string language?: string
theme?: string theme?: string
private?: boolean private?: boolean
role?: number
bahamutMode?: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
} }
const AccountModal = React.forwardRef<HTMLDivElement, Props>( const AccountModal = React.forwardRef<HTMLDivElement, Props>(
function AccountModal(props: Props, forwardedRef) { function AccountModal(props: Props, forwardedRef) {
// Localization // Localization
const t = useTranslations('common') const { t } = useTranslation('common')
const router = useRouter() const router = useRouter()
// In App Router, locale is handled via cookies const locale =
const currentLocale = getCookie('NEXT_LOCALE') as string || 'en' router.locale && ['en', 'ja'].includes(router.locale)
const locale = ['en', 'ja'].includes(currentLocale) ? currentLocale : 'en' ? router.locale
: 'en'
// useEffect only runs on the client, so now we can safely show the UI // useEffect only runs on the client, so now we can safely show the UI
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const { theme: appTheme, setTheme: setAppTheme } = useTheme() const { theme: appTheme, setTheme: setAppTheme } = useTheme()
// Cookies
const accountCookie = getCookie('account')
const userCookie = getCookie('user')
const cookieData = {
account: accountCookie ? JSON.parse(accountCookie as string) : undefined,
user: userCookie ? JSON.parse(userCookie as string) : undefined,
}
// UI State // UI State
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [selectOpenState, setSelectOpenState] = useState<StateVariables>({
picture: false,
gender: false,
language: false,
theme: false,
})
// Values // Values
const [username, setUsername] = useState(props.username || '') const [username, setUsername] = useState(props.username || '')
@ -57,7 +82,7 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
const [language, setLanguage] = useState(props.language || '') const [language, setLanguage] = useState(props.language || '')
const [gender, setGender] = useState(props.gender || 0) const [gender, setGender] = useState(props.gender || 0)
const [theme, setTheme] = useState(props.theme || 'system') const [theme, setTheme] = useState(props.theme || 'system')
const [bahamutMode, setBahamutMode] = useState(props.bahamutMode || false) // const [privateProfile, setPrivateProfile] = useState(false)
// Setup // Setup
const [pictureOpen, setPictureOpen] = useState(false) const [pictureOpen, setPictureOpen] = useState(false)
@ -123,6 +148,7 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
language: language, language: language,
gender: gender, gender: gender,
theme: theme, theme: theme,
// private: privateProfile,
}, },
} }
@ -140,7 +166,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
gender: user.gender, gender: user.gender,
language: user.language, language: user.language,
theme: user.theme, theme: user.theme,
bahamut: bahamutMode,
} }
const expiresAt = new Date() const expiresAt = new Date()
@ -151,7 +176,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
id: user.id, id: user.id,
username: user.username, username: user.username,
granblueId: '', granblueId: '',
role: user.role,
avatar: { avatar: {
picture: user.avatar.picture, picture: user.avatar.picture,
element: user.avatar.element, element: user.avatar.element,
@ -159,13 +183,11 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
language: user.language, language: user.language,
theme: user.theme, theme: user.theme,
gender: user.gender, gender: user.gender,
bahamut: bahamutMode,
} }
setOpen(false) setOpen(false)
if (props.onOpenChange) props.onOpenChange(false) if (props.onOpenChange) props.onOpenChange(false)
changeLanguage(router, user.language) changeLanguage(router, user.language)
if (props.bahamutMode != bahamutMode) router.refresh()
}) })
} }
} }
@ -175,20 +197,17 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
.sort((a, b) => (a.name.en > b.name.en ? 1 : -1)) .sort((a, b) => (a.name.en > b.name.en ? 1 : -1))
.map((item, i) => { .map((item, i) => {
return ( return (
<SelectItem <PictureSelectItem
key={`picture-${i}`} key={`picture-${i}`}
element={item.element} element={item.element}
icon={{ src={[
alt: item.name[locale], `/profile/${item.filename}.png`,
src: [ `/profile/${item.filename}@2x.png 2x`,
`/profile/${item.filename}.png`, ]}
`/profile/${item.filename}@2x.png 2x`,
],
}}
value={item.filename} value={item.filename}
> >
{item.name[locale]} {item.name[locale]}
</SelectItem> </PictureSelectItem>
) )
}) })
@ -196,17 +215,15 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
<SelectTableField <SelectTableField
name="picture" name="picture"
description={t('modals.settings.descriptions.picture')} description={t('modals.settings.descriptions.picture')}
className="image" className="Image"
label={t('modals.settings.labels.picture')} label={t('modals.settings.labels.picture')}
image={{
className: pictureData.find((i) => i.filename === picture)?.element,
src: [`/profile/${picture}.png`, `/profile/${picture}@2x.png 2x`],
alt: pictureData.find((i) => i.filename === picture)?.name[locale],
}}
open={pictureOpen} open={pictureOpen}
onOpenChange={() => openSelect('picture')} onOpenChange={() => openSelect('picture')}
onChange={handlePictureChange} onChange={handlePictureChange}
onClose={() => setPictureOpen(false)} onClose={() => setPictureOpen(false)}
imageAlt={t('modals.settings.labels.image_alt')}
imageClass={pictureData.find((i) => i.filename === picture)?.element}
imageSrc={[`/profile/${picture}.png`, `/profile/${picture}@2x.png 2x`]}
value={picture} value={picture}
> >
{pictureOptions} {pictureOptions}
@ -274,15 +291,6 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
</SelectTableField> </SelectTableField>
) )
const adminField = () => (
<SwitchTableField
name="admin"
label={t('modals.settings.labels.admin')}
value={props.bahamutMode}
onValueChange={(value: boolean) => setBahamutMode(value)}
/>
)
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
}, []) }, [])
@ -295,35 +303,38 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
<Dialog open={open} onOpenChange={openChange}> <Dialog open={open} onOpenChange={openChange}>
<DialogContent <DialogContent
className="Account" className="Account"
headerRef={headerRef} headerref={headerRef}
footerRef={footerRef} footerref={footerRef}
onOpenAutoFocus={(event: Event) => {}} onOpenAutoFocus={(event: Event) => {}}
onEscapeKeyDown={onEscapeKeyDown} onEscapeKeyDown={onEscapeKeyDown}
> >
<DialogHeader <div className="DialogHeader" ref={headerRef}>
title={`@${username}`} <div className="DialogTop">
subtitle={t('modals.settings.title')} <DialogTitle className="SubTitle">
/> {t('modals.settings.title')}
</DialogTitle>
<DialogTitle className="DialogTitle">@{username}</DialogTitle>
</div>
<DialogClose className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</DialogClose>
</div>
<form onSubmit={update}> <form onSubmit={update}>
<div className={styles.fields}> <div className="Fields">
{pictureField()} {pictureField()}
{genderField()} {genderField()}
{languageField()} {languageField()}
{themeField()} {themeField()}
{props.role === 9 && adminField()}
</div> </div>
<div className="DialogFooter" ref={footerRef}>
<DialogFooter <Button
ref={footerRef} contained={true}
rightElements={[ text={t('modals.settings.buttons.confirm')}
<Button />
bound={true} </div>
key="confirm"
text={t('modals.settings.buttons.confirm')}
/>,
]}
/>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View file

@ -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
}

View file

@ -1,4 +1,4 @@
.wrapper { .AlertWrapper {
align-items: center; align-items: center;
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -7,20 +7,10 @@
width: 100vw; width: 100vw;
top: 0; top: 0;
left: 0; left: 0;
z-index: 12; z-index: 31;
} }
.overlay { .Alert {
isolation: isolate;
position: fixed;
z-index: 9;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.alert {
animation: $duration-modal-open cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none animation: $duration-modal-open cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none
running openModalDesktop; running openModalDesktop;
background: var(--dialog-bg); background: var(--dialog-bg);
@ -29,23 +19,17 @@
flex-direction: column; flex-direction: column;
gap: $unit-2x; gap: $unit-2x;
min-width: 20vw; min-width: 20vw;
max-width: 40vw; max-width: 30vw;
padding: $unit * 4; padding: $unit * 4;
@include breakpoint(tablet) {
max-width: inherit;
max-width: 60vw;
}
@include breakpoint(phone) { @include breakpoint(phone) {
max-width: inherit; max-width: inherit;
width: 70vw; width: 60vw;
} }
.description { .description {
font-size: $font-regular; font-size: $font-regular;
line-height: 1.4; line-height: 1.4;
white-space: pre-line;
strong { strong {
font-weight: $bold; font-weight: $bold;
@ -56,27 +40,5 @@
display: flex; display: flex;
align-self: flex-end; align-self: flex-end;
gap: $unit; gap: $unit;
@include breakpoint(phone) {
flex-direction: column-reverse;
align-self: center;
width: 100%;
& > * {
width: 100%;
}
}
}
@keyframes openModalDesktop {
0% {
opacity: 0;
transform: scale(0.96);
}
100% {
// opacity: 1;
transform: scale(1);
}
} }
} }

View file

@ -1,9 +1,9 @@
import React from 'react' import React from 'react'
import * as AlertDialog from '@radix-ui/react-alert-dialog' import * as AlertDialog from '@radix-ui/react-alert-dialog'
import styles from './index.module.scss' import './index.scss'
import Button from '~components/common/Button' import Button from '~components/Button'
import Overlay from '~components/common/Overlay' import Overlay from '~components/Overlay'
// Props // Props
interface Props { interface Props {
@ -12,7 +12,6 @@ interface Props {
message: string | React.ReactNode message: string | React.ReactNode
primaryAction?: () => void primaryAction?: () => void
primaryActionText?: string primaryActionText?: string
primaryActionClassName?: string
cancelAction: () => void cancelAction: () => void
cancelActionText: string cancelActionText: string
} }
@ -21,44 +20,40 @@ const Alert = (props: Props) => {
return ( return (
<AlertDialog.Root open={props.open}> <AlertDialog.Root open={props.open}>
<AlertDialog.Portal> <AlertDialog.Portal>
<Overlay <AlertDialog.Overlay className="Overlay" onClick={props.cancelAction} />
className="alert" <div className="AlertWrapper">
open={props.open} <AlertDialog.Content className="Alert">
visible={true} {props.title ? (
onClick={props.cancelAction}
/>
<div className={styles.wrapper}>
<AlertDialog.Content
className={styles.alert}
onEscapeKeyDown={props.cancelAction}
>
{props.title && (
<AlertDialog.Title>{props.title}</AlertDialog.Title> <AlertDialog.Title>{props.title}</AlertDialog.Title>
) : (
''
)} )}
<AlertDialog.Description className={styles.description}> <AlertDialog.Description className="description">
{props.message} {props.message}
</AlertDialog.Description> </AlertDialog.Description>
<div className={styles.buttons}> <div className="buttons">
<AlertDialog.Cancel asChild> <AlertDialog.Cancel asChild>
<Button <Button
bound={true} contained={true}
onClick={props.cancelAction} onClick={props.cancelAction}
text={props.cancelActionText} text={props.cancelActionText}
/> />
</AlertDialog.Cancel> </AlertDialog.Cancel>
{props.primaryAction && ( {props.primaryAction ? (
<AlertDialog.Action asChild> <AlertDialog.Action asChild>
<Button <Button
className={props.primaryActionClassName} contained={true}
bound={true}
onClick={props.primaryAction} onClick={props.primaryAction}
text={props.primaryActionText} text={props.primaryActionText}
/> />
</AlertDialog.Action> </AlertDialog.Action>
) : (
''
)} )}
</div> </div>
</AlertDialog.Content> </AlertDialog.Content>
</div> </div>
<Overlay open={props.open} visible={true} />
</AlertDialog.Portal> </AlertDialog.Portal>
</AlertDialog.Root> </AlertDialog.Root>
) )

View 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;
}
}
}
}

View 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

View 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;
}
}
}
}
}

View file

@ -1,17 +1,15 @@
'use client' import React, { ForwardedRef, useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router'
import { getCookie } from 'cookies-next' import { useTranslation } from 'next-i18next'
import { useTranslations } from 'next-intl'
import Input from '~components/common/Input' import Select from '~components/Select'
import Select from '~components/common/Select' import SelectItem from '~components/SelectItem'
import SelectItem from '~components/common/SelectItem'
import classNames from 'classnames' import classNames from 'classnames'
import ax from '~data/ax' import ax from '~data/ax'
import styles from './index.module.scss' import './index.scss'
interface ErrorMap { interface ErrorMap {
[index: string]: string [index: string]: string
@ -33,8 +31,10 @@ interface Props {
} }
const AXSelect = (props: Props) => { const AXSelect = (props: Props) => {
const locale = (getCookie('NEXT_LOCALE') as string) || 'en' const router = useRouter()
const t = useTranslations('common') const locale =
router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const { t } = useTranslation('common')
const [openAX1, setOpenAX1] = useState(false) const [openAX1, setOpenAX1] = useState(false)
const [openAX2, setOpenAX2] = useState(false) const [openAX2, setOpenAX2] = useState(false)
@ -45,19 +45,14 @@ const AXSelect = (props: Props) => {
axValue2: '', axValue2: '',
}) })
const inputClasses = classNames({
fullHeight: true,
range: true,
})
const primaryErrorClasses = classNames({ const primaryErrorClasses = classNames({
[styles.errors]: true, errors: true,
[styles.visible]: errors.axValue1.length > 0, visible: errors.axValue1.length > 0,
}) })
const secondaryErrorClasses = classNames({ const secondaryErrorClasses = classNames({
[styles.errors]: true, errors: true,
[styles.visible]: errors.axValue2.length > 0, visible: errors.axValue2.length > 0,
}) })
// Refs // Refs
@ -69,12 +64,8 @@ const AXSelect = (props: Props) => {
// States // States
const [primaryAxModifier, setPrimaryAxModifier] = useState(-1) const [primaryAxModifier, setPrimaryAxModifier] = useState(-1)
const [secondaryAxModifier, setSecondaryAxModifier] = useState(-1) const [secondaryAxModifier, setSecondaryAxModifier] = useState(-1)
const [primaryAxValue, setPrimaryAxValue] = useState( const [primaryAxValue, setPrimaryAxValue] = useState(0.0)
props.currentSkills ? props.currentSkills[0].strength : 0.0 const [secondaryAxValue, setSecondaryAxValue] = useState(0.0)
)
const [secondaryAxValue, setSecondaryAxValue] = useState(
props.currentSkills ? props.currentSkills[1].strength : 0.0
)
useEffect(() => { useEffect(() => {
setupAx1() setupAx1()
@ -148,11 +139,8 @@ const AXSelect = (props: Props) => {
// Classes // Classes
const secondarySetClasses = classNames({ const secondarySetClasses = classNames({
[styles.set]: true, AXSet: true,
[styles.hidden]: hidden: primaryAxModifier < 0,
primaryAxModifier < 0 ||
primaryAxModifier === 18 ||
primaryAxModifier === 19,
}) })
function setupAx1() { function setupAx1() {
@ -276,17 +264,14 @@ const AXSelect = (props: Props) => {
secondaryAxModifierSelect.current && secondaryAxModifierSelect.current &&
secondaryAxValueInput.current secondaryAxValueInput.current
) { ) {
setupInput( setupInput(ax[props.axType - 1][value], primaryAxValueInput.current)
ax[props.axType - 1].find((ax) => ax.id === value),
primaryAxValueInput.current
)
setPrimaryAxValue(0) setPrimaryAxValue(0)
primaryAxValueInput.current.value = ''
// Reset the secondary AX modifier, reset the AX value and hide the input // Reset the secondary AX modifier, reset the AX value and hide the input
setSecondaryAxModifier(-1) setSecondaryAxModifier(-1)
setSecondaryAxValue(0) setSecondaryAxValue(0)
// secondaryAxValueInput.current.className = 'Input Contained' secondaryAxValueInput.current.className = 'Input Contained'
secondaryAxValueInput.current.value = '' secondaryAxValueInput.current.value = ''
} }
} }
@ -311,7 +296,7 @@ const AXSelect = (props: Props) => {
const value = parseFloat(event.target.value) const value = parseFloat(event.target.value)
let newErrors = { ...errors } let newErrors = { ...errors }
if (primaryAxValueInput.current === event.target) { if (primaryAxValueInput.current == event.target) {
if (handlePrimaryErrors(value)) setPrimaryAxValue(value) if (handlePrimaryErrors(value)) setPrimaryAxValue(value)
} else { } else {
if (handleSecondaryErrors(value)) setSecondaryAxValue(value) if (handleSecondaryErrors(value)) setSecondaryAxValue(value)
@ -319,18 +304,16 @@ const AXSelect = (props: Props) => {
} }
function handlePrimaryErrors(value: number) { function handlePrimaryErrors(value: number) {
const primaryAxSkill = ax[props.axType - 1].find( const primaryAxSkill = ax[props.axType - 1][primaryAxModifier]
(ax) => ax.id === primaryAxModifier
)
let newErrors = { ...errors } let newErrors = { ...errors }
if (primaryAxSkill && value < primaryAxSkill.minValue) { if (value < primaryAxSkill.minValue) {
newErrors.axValue1 = t('ax.errors.value_too_low', { newErrors.axValue1 = t('ax.errors.value_too_low', {
name: primaryAxSkill.name[locale], name: primaryAxSkill.name[locale],
minValue: primaryAxSkill.minValue, minValue: primaryAxSkill.minValue,
suffix: primaryAxSkill.suffix ? primaryAxSkill.suffix : '', suffix: primaryAxSkill.suffix ? primaryAxSkill.suffix : '',
}) })
} else if (primaryAxSkill && value > primaryAxSkill.maxValue) { } else if (value > primaryAxSkill.maxValue) {
newErrors.axValue1 = t('ax.errors.value_too_high', { newErrors.axValue1 = t('ax.errors.value_too_high', {
name: primaryAxSkill.name[locale], name: primaryAxSkill.name[locale],
maxValue: primaryAxSkill.maxValue, maxValue: primaryAxSkill.maxValue,
@ -338,7 +321,7 @@ const AXSelect = (props: Props) => {
}) })
} else if (!value || value <= 0) { } else if (!value || value <= 0) {
newErrors.axValue1 = t('ax.errors.value_empty', { newErrors.axValue1 = t('ax.errors.value_empty', {
name: primaryAxSkill?.name[locale] || '', name: primaryAxSkill.name[locale],
}) })
} else { } else {
newErrors.axValue1 = '' newErrors.axValue1 = ''
@ -391,11 +374,10 @@ const AXSelect = (props: Props) => {
} }
function setupInput(ax: ItemSkill | undefined, element: HTMLInputElement) { function setupInput(ax: ItemSkill | undefined, element: HTMLInputElement) {
console.log(ax)
if (ax) { if (ax) {
const rangeString = `${ax.minValue}~${ax.maxValue}${ax.suffix || ''}` const rangeString = `${ax.minValue}~${ax.maxValue}${ax.suffix || ''}`
// element.className = 'Input Bound Visible' element.className = 'Input Bound Visible'
element.disabled = false element.disabled = false
element.placeholder = rangeString element.placeholder = rangeString
element.min = `${ax.minValue}` element.min = `${ax.minValue}`
@ -404,12 +386,12 @@ const AXSelect = (props: Props) => {
} else { } else {
if (primaryAxValueInput.current && secondaryAxValueInput.current) { if (primaryAxValueInput.current && secondaryAxValueInput.current) {
if (primaryAxValueInput.current == element) { if (primaryAxValueInput.current == element) {
// primaryAxValueInput.current.className = 'Input Contained' primaryAxValueInput.current.className = 'Input Contained'
primaryAxValueInput.current.disabled = true primaryAxValueInput.current.disabled = true
primaryAxValueInput.current.placeholder = '' primaryAxValueInput.current.placeholder = ''
} }
// secondaryAxValueInput.current.className = 'Input Contained' secondaryAxValueInput.current.className = 'Input Contained'
secondaryAxValueInput.current.disabled = true secondaryAxValueInput.current.disabled = true
secondaryAxValueInput.current.placeholder = '' secondaryAxValueInput.current.placeholder = ''
} }
@ -417,33 +399,28 @@ const AXSelect = (props: Props) => {
} }
return ( return (
<div className={styles.ax}> <div className="AXSelect">
<div className={styles.set}> <div className="AXSet">
<div className={styles.fields}> <div className="fields">
<Select <Select
key="ax1" key="ax1"
value={`${primaryAxModifier}`} value={`${primaryAxModifier}`}
open={openAX1} open={openAX1}
trigger={{
bound: true,
className: 'grow',
}}
onClose={() => onClose(1)} onClose={() => onClose(1)}
onOpenChange={() => openSelect(1)} onOpenChange={() => openSelect(1)}
onValueChange={handleAX1SelectChange} onValueChange={handleAX1SelectChange}
triggerClass="modal"
overlayVisible={false} overlayVisible={false}
> >
{generateOptions(0)} {generateOptions(0)}
</Select> </Select>
<Input <input
className={inputClasses} defaultValue={
wrapperClassName="fullHeight" props.currentSkills && props.currentSkills[0]
fieldsetClassName={classNames({ ? props.currentSkills[0].strength
hidden: primaryAxModifier < 0, : 0
})} }
bound={true}
value={primaryAxValue}
type="number" type="number"
onChange={handleInputChange} onChange={handleInputChange}
ref={primaryAxValueInput} ref={primaryAxValueInput}
@ -453,31 +430,26 @@ const AXSelect = (props: Props) => {
</div> </div>
<div className={secondarySetClasses}> <div className={secondarySetClasses}>
<div className={styles.fields}> <div className="fields">
<Select <Select
key="ax2" key="ax2"
value={`${secondaryAxModifier}`} value={`${secondaryAxModifier}`}
open={openAX2} open={openAX2}
trigger={{
bound: true,
className: 'grow',
}}
onClose={() => onClose(2)} onClose={() => onClose(2)}
onOpenChange={() => openSelect(2)} onOpenChange={() => openSelect(2)}
onValueChange={handleAX2SelectChange} onValueChange={handleAX2SelectChange}
triggerClass="modal"
ref={secondaryAxModifierSelect} ref={secondaryAxModifierSelect}
overlayVisible={false} overlayVisible={false}
> >
{generateOptions(1)} {generateOptions(1)}
</Select> </Select>
<Input <input
className={inputClasses} defaultValue={
wrapperClassName="fullHeight" props.currentSkills && props.currentSkills[1]
fieldsetClassName={classNames({ ? props.currentSkills[1].strength
hidden: secondaryAxModifier < 0, : 0
})} }
bound={true}
value={secondaryAxValue}
type="number" type="number"
onChange={handleInputChange} onChange={handleInputChange}
ref={secondaryAxValueInput} ref={secondaryAxValueInput}

View 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%;
}
}

View file

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import styles from './index.module.scss' import './index.scss'
interface Props interface Props
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
@ -14,18 +14,16 @@ interface Props
rightAccessoryClassName?: string rightAccessoryClassName?: string
active?: boolean active?: boolean
blended?: boolean blended?: boolean
bound?: boolean contained?: boolean
floating?: boolean buttonSize?: 'small' | 'medium' | 'large'
size?: 'icon' | 'small' | 'medium' | 'large'
text?: string text?: string
} }
const defaultProps = { const defaultProps = {
active: false, active: false,
blended: false, blended: false,
bound: false, contained: false,
floating: false, buttonSize: 'medium' as const,
size: 'medium' as const,
} }
const Button = React.forwardRef<HTMLButtonElement, Props>(function button( const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
@ -36,44 +34,29 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
rightAccessoryClassName, rightAccessoryClassName,
active, active,
blended, blended,
floating, contained,
bound, buttonSize,
size,
text, text,
...props ...props
}, },
forwardedRef forwardedRef
) { ) {
const classes = classNames( const classes = classNames(buttonSize, props.className, {
{ Button: true,
[styles.button]: true, Active: active,
[styles.active]: active, Blended: blended,
[styles.bound]: bound, Contained: contained,
[styles.blended]: blended, })
[styles.floating]: floating,
[styles.icon]: size === 'icon',
[styles.small]: size === 'small',
[styles.medium]: size === 'medium' || !size,
[styles.large]: size === 'large',
},
props.className?.split(' ').map((className) => styles[className])
)
const leftAccessoryClasses = classNames( const leftAccessoryClasses = classNames(leftAccessoryClassName, {
{ Accessory: true,
[styles.accessory]: true, Left: true,
[styles.left]: true, })
},
leftAccessoryClassName?.split(' ').map((className) => styles[className])
)
const rightAccessoryClasses = classNames( const rightAccessoryClasses = classNames(rightAccessoryClassName, {
{ Accessory: true,
[styles.accessory]: true, Right: true,
[styles.right]: true, })
},
rightAccessoryClassName?.split(' ').map((className) => styles[className])
)
const hasLeftAccessory = () => { const hasLeftAccessory = () => {
if (leftAccessoryIcon) if (leftAccessoryIcon)
@ -86,7 +69,7 @@ const Button = React.forwardRef<HTMLButtonElement, Props>(function button(
} }
const hasText = () => { const hasText = () => {
if (text) return <span className={styles.text}>{text}</span> if (text) return <span className="Text">{text}</span>
} }
return ( return (

View file

@ -1,4 +1,4 @@
.unit { .ChangelogUnit {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit; gap: $unit;

View 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

View 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;
}
}
}

View 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

View file

@ -1,19 +1,15 @@
'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation' import { useRouter } from 'next/router'
import { getCookie } from 'cookies-next' import { Trans, useTranslation } from 'next-i18next'
import { useTranslations } from 'next-intl'
import { Dialog } from '~components/common/Dialog' import { Dialog } from '~components/Dialog'
import DialogContent from '~components/common/DialogContent' import DialogContent from '~components/DialogContent'
import DialogFooter from '~components/common/DialogFooter' import Button from '~components/Button'
import Button from '~components/common/Button' import Overlay from '~components/Overlay'
import Overlay from '~components/common/Overlay'
import { appState } from '~utils/appState' import { appState } from '~utils/appState'
import styles from './index.module.scss' import './index.scss'
interface Props { interface Props {
open: boolean open: boolean
@ -27,12 +23,9 @@ interface Props {
const CharacterConflictModal = (props: Props) => { const CharacterConflictModal = (props: Props) => {
// Localization // Localization
const router = useRouter() const router = useRouter()
const pathname = usePathname() const { t } = useTranslation('common')
const searchParams = useSearchParams()
const t = useTranslations('common')
const routerLocale = getCookie('NEXT_LOCALE')
const locale = const locale =
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en' router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// States // States
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@ -66,7 +59,7 @@ const CharacterConflictModal = (props: Props) => {
suffix = `${suffix}_0${element}` suffix = `${suffix}_0${element}`
} }
return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-square/${character?.granblue_id}_${suffix}.jpg` return `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-square/${character?.granblue_id}_${suffix}.jpg`
} }
function openChange(open: boolean) { function openChange(open: boolean) {
@ -82,21 +75,19 @@ const CharacterConflictModal = (props: Props) => {
return ( return (
<Dialog open={open} onOpenChange={openChange}> <Dialog open={open} onOpenChange={openChange}>
<DialogContent <DialogContent
className="conflict" className="Conflict"
footerRef={footerRef} footerref={footerRef}
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={close} onEscapeKeyDown={close}
> >
<div className={styles.content}> <div className="Content">
<p> <p>
{t.rich('modals.conflict.character', { <Trans i18nKey="modals.conflict.character"></Trans>
strong: (chunks) => <strong>{chunks}</strong>
})}
</p> </p>
<div className={styles.diagram}> <div className="CharacterDiagram Diagram">
<ul> <ul>
{props.conflictingCharacters?.map((character, i) => ( {props.conflictingCharacters?.map((character, i) => (
<li className={styles.character} key={`conflict-${i}`}> <li className="character" key={`conflict-${i}`}>
<img <img
alt={character.object.name[locale]} alt={character.object.name[locale]}
src={imageUrl(character.object, character.uncap_level)} src={imageUrl(character.object, character.uncap_level)}
@ -105,9 +96,9 @@ const CharacterConflictModal = (props: Props) => {
</li> </li>
))} ))}
</ul> </ul>
<span className={styles.arrow}>&rarr;</span> <span className="arrow">&rarr;</span>
<div className={styles.wrapper}> <div className="wrapper">
<div className={styles.character}> <div className="character">
<img <img
alt={props.incomingCharacter?.name[locale]} alt={props.incomingCharacter?.name[locale]}
src={imageUrl(props.incomingCharacter)} src={imageUrl(props.incomingCharacter)}
@ -117,22 +108,20 @@ const CharacterConflictModal = (props: Props) => {
</div> </div>
</div> </div>
</div> </div>
<DialogFooter <div className="DialogFooter" ref={footerRef}>
rightElements={[ <div className="Buttons Span">
<Button <Button
bound={true} contained={true}
onClick={close} onClick={close}
key="cancel"
text={t('buttons.cancel')} text={t('buttons.cancel')}
/>, />
<Button <Button
bound={true} contained={true}
onClick={props.resolveConflict} onClick={props.resolveConflict}
key="confirm"
text={t('modals.conflict.buttons.confirm')} text={t('modals.conflict.buttons.confirm')}
/>, />
]} </div>
/> </div>
</DialogContent> </DialogContent>
<Overlay open={open} visible={true} /> <Overlay open={open} visible={true} />
</Dialog> </Dialog>

View 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;
}
}

View file

@ -2,22 +2,22 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { getCookie } from 'cookies-next' import { getCookie } from 'cookies-next'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { useTranslations } from 'next-intl' import { useTranslation } from 'next-i18next'
import { AxiosError, AxiosResponse } from 'axios' import { AxiosError, AxiosResponse } from 'axios'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import Alert from '~components/common/Alert' import Alert from '~components/Alert'
import JobSection from '~components/job/JobSection' import JobSection from '~components/JobSection'
import CharacterUnit from '~components/character/CharacterUnit' import CharacterUnit from '~components/CharacterUnit'
import CharacterConflictModal from '~components/character/CharacterConflictModal' import CharacterConflictModal from '~components/CharacterConflictModal'
import type { DetailsObject, JobSkillObject, SearchableObject } from '~types' import type { DetailsObject, JobSkillObject, SearchableObject } from '~types'
import api from '~utils/api' import api from '~utils/api'
import { appState } from '~utils/appState' import { appState } from '~utils/appState'
import styles from './index.module.scss' import './index.scss'
// Props // Props
interface Props { interface Props {
@ -33,7 +33,7 @@ const CharacterGrid = (props: Props) => {
const numCharacters: number = 5 const numCharacters: number = 5
// Localization // Localization
const t = useTranslations('common') const { t } = useTranslation('common')
// Cookies // Cookies
const cookie = getCookie('account') const cookie = getCookie('account')
@ -100,19 +100,13 @@ const CharacterGrid = (props: Props) => {
if (!party.id) { if (!party.id) {
props.createParty().then((team) => { props.createParty().then((team) => {
saveCharacter(team.id, character, position) saveCharacter(team.id, character, position)
.then((response) => { .then((response) => storeGridCharacter(response.data))
const data = response.data['grid_character']
storeGridCharacter(data)
})
.catch((error) => console.error(error)) .catch((error) => console.error(error))
}) })
} else { } else {
if (props.editable) if (props.editable)
saveCharacter(party.id, character, position) saveCharacter(party.id, character, position)
.then((response) => { .then((response) => handleCharacterResponse(response.data))
const data = response.data['grid_character']
handleCharacterResponse(data)
})
.catch((error) => { .catch((error) => {
const axiosError = error as AxiosError const axiosError = error as AxiosError
const response = axiosError.response const response = axiosError.response
@ -132,7 +126,6 @@ const CharacterGrid = (props: Props) => {
setPosition(data.position) setPosition(data.position)
setModalOpen(true) setModalOpen(true)
} else { } else {
console.log(data)
storeGridCharacter(data) storeGridCharacter(data)
} }
} }
@ -187,7 +180,6 @@ const CharacterGrid = (props: Props) => {
setPosition(-1) setPosition(-1)
setConflicts([]) setConflicts([])
setIncoming(undefined) setIncoming(undefined)
setModalOpen(false)
} }
async function removeCharacter(id: string) { async function removeCharacter(id: string) {
@ -267,23 +259,6 @@ const CharacterGrid = (props: Props) => {
} }
} }
function removeJobSkill(position: number) {
if (party.id && props.editable) {
api
.removeJobSkill({ partyId: party.id, position: position })
.then((response) => {
// Update the current skills
const newSkills = response.data.job_skills
setJobSkills(newSkills)
appState.party.jobSkills = newSkills
})
.catch((error) => {
const data = error.response.data
console.log(data)
})
}
}
async function saveAccessory(accessory: JobAccessory) { async function saveAccessory(accessory: JobAccessory) {
const payload = { const payload = {
party: { party: {
@ -508,9 +483,7 @@ const CharacterGrid = (props: Props) => {
<Alert <Alert
open={errorAlertOpen} open={errorAlertOpen}
title={axiosError ? `${axiosError.status}` : 'Error'} title={axiosError ? `${axiosError.status}` : 'Error'}
message={axiosError?.statusText && axiosError.statusText !== 'undefined' message={t(`errors.${axiosError?.statusText.toLowerCase()}`)}
? t(`errors.${axiosError.statusText.toLowerCase()}`)
: t('errors.internal_server_error.description')}
cancelAction={() => setErrorAlertOpen(false)} cancelAction={() => setErrorAlertOpen(false)}
cancelActionText={t('buttons.confirm')} cancelActionText={t('buttons.confirm')}
/> />
@ -525,7 +498,7 @@ const CharacterGrid = (props: Props) => {
cancelAction={cancelAlert} cancelAction={cancelAlert}
cancelActionText={'Got it'} cancelActionText={'Got it'}
/> />
<div className={styles.grid}> <div id="CharacterGrid">
<JobSection <JobSection
job={job} job={job}
jobSkills={jobSkills} jobSkills={jobSkills}
@ -533,7 +506,6 @@ const CharacterGrid = (props: Props) => {
editable={props.editable} editable={props.editable}
saveJob={saveJob} saveJob={saveJob}
saveSkill={saveJobSkill} saveSkill={saveJobSkill}
removeSkill={removeJobSkill}
saveAccessory={saveAccessory} saveAccessory={saveAccessory}
/> />
<CharacterConflictModal <CharacterConflictModal
@ -544,7 +516,7 @@ const CharacterGrid = (props: Props) => {
resolveConflict={resolveConflict} resolveConflict={resolveConflict}
resetConflict={resetConflict} resetConflict={resetConflict}
/> />
<ul className={styles.characters}> <ul id="Characters">
{Array.from(Array(numCharacters)).map((x, i) => { {Array.from(Array(numCharacters)).map((x, i) => {
return ( return (
<li key={`grid_unit_${i}`}> <li key={`grid_unit_${i}`}>

View file

@ -1,5 +1,20 @@
.content { .Character.HovercardContent {
.mastery { .title .Image {
position: relative;
.Perpetuity {
position: absolute;
background-image: url('/icons/perpetuity/filled.svg');
background-size: $unit-3x $unit-3x;
z-index: 20;
top: $unit-half * -1;
right: $unit-3x;
width: $unit-3x;
height: $unit-3x;
}
}
.Mastery {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit; gap: $unit;
@ -9,7 +24,7 @@
flex-direction: column; flex-direction: column;
gap: $unit-half; gap: $unit-half;
.extendedMastery { .ExtendedMastery {
align-items: center; align-items: center;
display: flex; display: flex;
gap: $unit-half; gap: $unit-half;
@ -25,7 +40,7 @@
} }
} }
.awakening { .Awakening {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit; gap: $unit;
@ -44,4 +59,10 @@
} }
} }
} }
// .Footer {
// position: sticky;
// bottom: 0;
// left: 0;
// }
} }

View file

@ -1,28 +1,25 @@
'use client'
import React from 'react' import React from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation' import { useRouter } from 'next/router'
import { getCookie } from 'cookies-next' import { useTranslation } from 'next-i18next'
import { useTranslations } from 'next-intl'
import { import {
Hovercard, Hovercard,
HovercardContent, HovercardContent,
HovercardTrigger, HovercardTrigger,
} from '~components/common/Hovercard' } from '~components/Hovercard'
import Button from '~components/common/Button' import Button from '~components/Button'
import WeaponLabelIcon from '~components/weapon/WeaponLabelIcon' import WeaponLabelIcon from '~components/WeaponLabelIcon'
import UncapIndicator from '~components/uncap/UncapIndicator' import UncapIndicator from '~components/UncapIndicator'
import { import {
overMastery, overMastery,
aetherialMastery, aetherialMastery,
permanentMastery, permanentMastery,
} from '~data/overMastery' } from '~data/overMastery'
import { characterAwakening } from '~data/awakening'
import { ExtendedMastery } from '~types' import { ExtendedMastery } from '~types'
import styles from './index.module.scss' import './index.scss'
import HovercardHeader from '~components/HovercardHeader'
interface Props { interface Props {
gridCharacter: GridCharacter gridCharacter: GridCharacter
@ -30,16 +27,34 @@ interface Props {
onTriggerClick: () => void onTriggerClick: () => void
} }
interface KeyNames {
[key: string]: {
en: string
jp: string
}
}
const CharacterHovercard = (props: Props) => { const CharacterHovercard = (props: Props) => {
const router = useRouter() const router = useRouter()
const pathname = usePathname() const { t } = useTranslation('common')
const searchParams = useSearchParams()
const t = useTranslations('common')
const routerLocale = getCookie('NEXT_LOCALE')
const locale = const locale =
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en' router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light'] const Element = ['null', 'wind', 'fire', 'water', 'earth', 'dark', 'light']
const Proficiency = [
'none',
'sword',
'dagger',
'axe',
'spear',
'bow',
'staff',
'fist',
'harp',
'gun',
'katana',
]
const tintElement = Element[props.gridCharacter.object.element] const tintElement = Element[props.gridCharacter.object.element]
function goTo() { function goTo() {
@ -49,6 +64,30 @@ const CharacterHovercard = (props: Props) => {
window.open(url, '_blank') window.open(url, '_blank')
} }
const perpetuity = () => {
if (props.gridCharacter && props.gridCharacter.perpetuity) {
return <i className="Perpetuity" />
}
}
function characterImage() {
let imgSrc = ''
if (props.gridCharacter) {
const character = props.gridCharacter.object
// Change the image based on the uncap level
let suffix = '01'
if (props.gridCharacter.uncap_level == 6) suffix = '04'
else if (props.gridCharacter.uncap_level == 5) suffix = '03'
else if (props.gridCharacter.uncap_level > 2) suffix = '02'
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-grid/${character.granblue_id}_${suffix}.jpg`
}
return imgSrc
}
function masteryElement(dictionary: ItemSkill[], mastery: ExtendedMastery) { function masteryElement(dictionary: ItemSkill[], mastery: ExtendedMastery) {
const canonicalMastery = dictionary.find( const canonicalMastery = dictionary.find(
(item) => item.id === mastery.modifier (item) => item.id === mastery.modifier
@ -56,7 +95,7 @@ const CharacterHovercard = (props: Props) => {
if (canonicalMastery) { if (canonicalMastery) {
return ( return (
<li className={styles.extendedMastery} key={canonicalMastery.id}> <li className="ExtendedMastery" key={canonicalMastery.id}>
<img <img
alt={canonicalMastery.name[locale]} alt={canonicalMastery.name[locale]}
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/mastery/${canonicalMastery.slug}.png`} src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/mastery/${canonicalMastery.slug}.png`}
@ -71,25 +110,21 @@ const CharacterHovercard = (props: Props) => {
} }
const overMasterySection = () => { const overMasterySection = () => {
if ( if (props.gridCharacter && props.gridCharacter.over_mastery) {
props.gridCharacter &&
props.gridCharacter.over_mastery &&
props.gridCharacter.over_mastery.length > 0
) {
return ( return (
<section className={styles.mastery}> <section className="Mastery">
<h5 className={tintElement}> <h5 className={tintElement}>
{t('modals.characters.subtitles.ring')} {t('modals.characters.subtitles.ring')}
</h5> </h5>
<ul> <ul>
{[...Array(4)].map((e, i) => { {[...Array(4)].map((e, i) => {
const ringIndex = i + 1
const ringStat: ExtendedMastery = const ringStat: ExtendedMastery =
props.gridCharacter.over_mastery[i] props.gridCharacter.over_mastery[i]
if (ringStat && ringStat.modifier && ringStat.modifier > 0) { if (ringStat && ringStat.modifier && ringStat.modifier > 0) {
if (i === 0 || i === 1) { if (ringIndex === 1 || ringIndex === 2) {
return masteryElement(overMastery.a, ringStat) return masteryElement(overMastery.a, ringStat)
} else if (i === 2) { } else if (ringIndex === 3) {
return masteryElement(overMastery.b, ringStat) return masteryElement(overMastery.b, ringStat)
} else { } else {
return masteryElement(overMastery.c, ringStat) return masteryElement(overMastery.c, ringStat)
@ -105,12 +140,11 @@ const CharacterHovercard = (props: Props) => {
const aetherialMasterySection = () => { const aetherialMasterySection = () => {
if ( if (
props.gridCharacter && props.gridCharacter &&
props.gridCharacter.over_mastery &&
props.gridCharacter.aetherial_mastery && props.gridCharacter.aetherial_mastery &&
props.gridCharacter.aetherial_mastery?.modifier > 0 props.gridCharacter.aetherial_mastery.modifier > 0
) { ) {
return ( return (
<section className={styles.mastery}> <section className="Mastery">
<h5 className={tintElement}> <h5 className={tintElement}>
{t('modals.characters.subtitles.earring')} {t('modals.characters.subtitles.earring')}
</h5> </h5>
@ -128,7 +162,7 @@ const CharacterHovercard = (props: Props) => {
const permanentMasterySection = () => { const permanentMasterySection = () => {
if (props.gridCharacter && props.gridCharacter.perpetuity) { if (props.gridCharacter && props.gridCharacter.perpetuity) {
return ( return (
<section className={styles.mastery}> <section className="Mastery">
<h5 className={tintElement}> <h5 className={tintElement}>
{t('modals.characters.subtitles.permanent')} {t('modals.characters.subtitles.permanent')}
</h5> </h5>
@ -146,22 +180,28 @@ const CharacterHovercard = (props: Props) => {
} }
const awakeningSection = () => { const awakeningSection = () => {
if (props.gridCharacter.awakening) { const gridAwakening = props.gridCharacter.awakening
const gridAwakening = props.gridCharacter.awakening const awakening = characterAwakening.find(
(awakening) => awakening.id === gridAwakening?.type
)
if (gridAwakening && awakening) {
return ( return (
<section className={styles.awakening}> <section className="Awakening">
<h5 className={tintElement}> <h5 className={tintElement}>
{t('modals.characters.subtitles.awakening')} {t('modals.characters.subtitles.awakening')}
</h5> </h5>
<div> <div>
{gridAwakening.type.slug !== 'character-balanced' && ( {gridAwakening.type > 1 ? (
<img <img
alt={gridAwakening.type.name[locale]} alt={awakening.name[locale]}
src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/awakening/${gridAwakening.type.slug}.jpg`} src={`${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/awakening/character_${gridAwakening.type}.jpg`}
/> />
) : (
''
)} )}
<span> <span>
<strong>{`${gridAwakening.type.name[locale]}`}</strong>&nbsp; <strong>{`${awakening.name[locale]}`}</strong>&nbsp;
{`Lv${gridAwakening.level}`} {`Lv${gridAwakening.level}`}
</span> </span>
</div> </div>
@ -175,7 +215,7 @@ const CharacterHovercard = (props: Props) => {
className={tintElement} className={tintElement}
text={t('buttons.wiki')} text={t('buttons.wiki')}
onClick={goTo} onClick={goTo}
bound={true} contained={true}
/> />
) )
@ -184,12 +224,51 @@ const CharacterHovercard = (props: Props) => {
<HovercardTrigger asChild onClick={props.onTriggerClick}> <HovercardTrigger asChild onClick={props.onTriggerClick}>
{props.children} {props.children}
</HovercardTrigger> </HovercardTrigger>
<HovercardContent className={styles.content} side="top"> <HovercardContent className="Character" side="top">
<HovercardHeader <div className="top">
gridObject={props.gridCharacter} <div className="title">
object={props.gridCharacter.object} <h4>{props.gridCharacter.object.name[locale]}</h4>
type="character" <div className="Image">
/> {perpetuity()}
<img
alt={props.gridCharacter.object.name[locale]}
src={characterImage()}
/>
</div>
</div>
<div className="subInfo">
<div className="icons">
<WeaponLabelIcon
labelType={Element[props.gridCharacter.object.element]}
/>
<WeaponLabelIcon
labelType={
Proficiency[
props.gridCharacter.object.proficiency.proficiency1
]
}
/>
{props.gridCharacter.object.proficiency.proficiency2 ? (
<WeaponLabelIcon
labelType={
Proficiency[
props.gridCharacter.object.proficiency.proficiency2
]
}
/>
) : (
''
)}
</div>
<UncapIndicator
type="character"
ulb={props.gridCharacter.object.uncap.ulb || false}
flb={props.gridCharacter.object.uncap.flb || false}
transcendenceStage={props.gridCharacter.transcendence_step}
special={props.gridCharacter.object.special}
/>
</div>
</div>
{wikiButton} {wikiButton}
{awakeningSection()} {awakeningSection()}
{overMasterySection()} {overMasterySection()}

View 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;
}
}
}
}

View 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

View file

@ -1,4 +1,4 @@
.result { .CharacterResult {
border-radius: 6px; border-radius: 6px;
display: flex; display: flex;
gap: $unit; gap: $unit;
@ -8,7 +8,7 @@
background: var(--button-contained-bg); background: var(--button-contained-bg);
cursor: pointer; cursor: pointer;
.info h5 { .Info h5 {
color: var(--text-primary); color: var(--text-primary);
} }
} }
@ -21,7 +21,7 @@
width: 120px; width: 120px;
} }
.info { .Info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
@ -32,7 +32,6 @@
display: inline-block; display: inline-block;
font-size: $font-medium; font-size: $font-medium;
font-weight: $medium; font-weight: $medium;
opacity: 1;
} }
.UncapIndicator { .UncapIndicator {

View 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

View file

@ -1,10 +1,14 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl' import { useTranslation } from 'next-i18next'
import cloneDeep from 'lodash.clonedeep' import cloneDeep from 'lodash.clonedeep'
import SearchFilter from '~components/search/SearchFilter' import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import SearchFilterCheckboxItem from '~components/search/SearchFilterCheckboxItem'
import SearchFilter from '~components/SearchFilter'
import SearchFilterCheckboxItem from '~components/SearchFilterCheckboxItem'
import './index.scss'
import { import {
emptyElementState, emptyElementState,
emptyProficiencyState, emptyProficiencyState,
@ -12,14 +16,12 @@ import {
} from '~utils/emptyStates' } from '~utils/emptyStates'
import { elements, proficiencies, rarities } from '~utils/stateValues' import { elements, proficiencies, rarities } from '~utils/stateValues'
import styles from './index.module.scss'
interface Props { interface Props {
sendFilters: (filters: { [key: string]: number[] }) => void sendFilters: (filters: { [key: string]: number[] }) => void
} }
const CharacterSearchFilterBar = (props: Props) => { const CharacterSearchFilterBar = (props: Props) => {
const t = useTranslations('common') const { t } = useTranslation('common')
const [rarityMenu, setRarityMenu] = useState(false) const [rarityMenu, setRarityMenu] = useState(false)
const [elementMenu, setElementMenu] = useState(false) const [elementMenu, setElementMenu] = useState(false)
@ -142,90 +144,121 @@ const CharacterSearchFilterBar = (props: Props) => {
return ( return (
<SearchFilter <SearchFilter
label={`${t('filters.labels.proficiency')} ${proficiency}`} label={`${t('filters.labels.proficiency')} ${proficiency}`}
display="grid"
numSelected={numSelected} numSelected={numSelected}
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
> >
{Array.from(Array(proficiencies.length)).map((x, i) => { <DropdownMenu.Label className="Label">{`${t(
const checked = 'filters.labels.proficiency'
proficiency == 1 )} ${proficiency}`}</DropdownMenu.Label>
? proficiency1State[proficiencies[i]].checked <section>
: proficiency2State[proficiencies[i]].checked <DropdownMenu.Group className="Group">
{Array.from(Array(proficiencies.length / 2)).map((x, i) => {
const checked =
proficiency == 1
? proficiency1State[proficiencies[i]].checked
: proficiency2State[proficiencies[i]].checked
return ( return (
<SearchFilterCheckboxItem <SearchFilterCheckboxItem
key={proficiencies[i]} key={proficiencies[i]}
onCheckedChange={onCheckedChange} onCheckedChange={onCheckedChange}
checked={checked} checked={checked}
valueKey={proficiencies[i]} valueKey={proficiencies[i]}
> >
{t(`proficiencies.${proficiencies[i]}`)} {t(`proficiencies.${proficiencies[i]}`)}
</SearchFilterCheckboxItem> </SearchFilterCheckboxItem>
) )
})} })}
</DropdownMenu.Group>
<DropdownMenu.Group className="Group">
{Array.from(Array(proficiencies.length / 2)).map((x, i) => {
const checked =
proficiency == 1
? proficiency1State[
proficiencies[i + proficiencies.length / 2]
].checked
: proficiency2State[
proficiencies[i + proficiencies.length / 2]
].checked
return (
<SearchFilterCheckboxItem
key={proficiencies[i + proficiencies.length / 2]}
onCheckedChange={onCheckedChange}
checked={checked}
valueKey={proficiencies[i + proficiencies.length / 2]}
>
{t(
`proficiencies.${
proficiencies[i + proficiencies.length / 2]
}`
)}
</SearchFilterCheckboxItem>
)
})}
</DropdownMenu.Group>
</section>
</SearchFilter> </SearchFilter>
) )
} }
const rarityFilter = (
<SearchFilter
label={t('filters.labels.rarity')}
display="list"
numSelected={
Object.values(rarityState)
.map((x) => x.checked)
.filter(Boolean).length
}
open={rarityMenu}
onOpenChange={rarityMenuOpened}
>
{Array.from(Array(rarities.length)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={rarities[i]}
onCheckedChange={handleRarityChange}
checked={rarityState[rarities[i]].checked}
valueKey={rarities[i]}
>
{t(`rarities.${rarities[i]}`)}
</SearchFilterCheckboxItem>
)
})}
</SearchFilter>
)
const elementFilter = (
<SearchFilter
label={t('filters.labels.element')}
display="list"
numSelected={
Object.values(elementState)
.map((x) => x.checked)
.filter(Boolean).length
}
open={elementMenu}
onOpenChange={elementMenuOpened}
>
{Array.from(Array(elements.length)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={elements[i]}
onCheckedChange={handleElementChange}
checked={elementState[elements[i]].checked}
valueKey={elements[i]}
>
{t(`elements.${elements[i]}`)}
</SearchFilterCheckboxItem>
)
})}
</SearchFilter>
)
return ( return (
<div className={styles.filterBar}> <div className="SearchFilterBar">
{rarityFilter} <SearchFilter
{elementFilter} label={t('filters.labels.rarity')}
numSelected={
Object.values(rarityState)
.map((x) => x.checked)
.filter(Boolean).length
}
open={rarityMenu}
onOpenChange={rarityMenuOpened}
>
<DropdownMenu.Label className="Label">
{t('filters.labels.rarity')}
</DropdownMenu.Label>
{Array.from(Array(rarities.length)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={rarities[i]}
onCheckedChange={handleRarityChange}
checked={rarityState[rarities[i]].checked}
valueKey={rarities[i]}
>
{t(`rarities.${rarities[i]}`)}
</SearchFilterCheckboxItem>
)
})}
</SearchFilter>
<SearchFilter
label={t('filters.labels.element')}
numSelected={
Object.values(elementState)
.map((x) => x.checked)
.filter(Boolean).length
}
open={elementMenu}
onOpenChange={elementMenuOpened}
>
<DropdownMenu.Label className="Label">
{t('filters.labels.element')}
</DropdownMenu.Label>
{Array.from(Array(elements.length)).map((x, i) => {
return (
<SearchFilterCheckboxItem
key={elements[i]}
onCheckedChange={handleElementChange}
checked={elementState[elements[i]].checked}
valueKey={elements[i]}
>
{t(`elements.${elements[i]}`)}
</SearchFilterCheckboxItem>
)
})}
</SearchFilter>
{renderProficiencyFilter(1)} {renderProficiencyFilter(1)}
{renderProficiencyFilter(2)} {renderProficiencyFilter(2)}
</div> </div>

View file

@ -1,4 +1,4 @@
.unit { .CharacterUnit {
align-items: center; align-items: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -8,7 +8,7 @@
position: relative; position: relative;
margin-bottom: $unit * 4; margin-bottom: $unit * 4;
&.editable .image:hover { &.editable .CharacterImage:hover {
border: $hover-stroke; border: $hover-stroke;
box-shadow: $hover-shadow; box-shadow: $hover-shadow;
cursor: pointer; cursor: pointer;
@ -60,7 +60,7 @@
z-index: 2; z-index: 2;
} }
.image { .CharacterImage {
aspect-ratio: 131 / 273; aspect-ratio: 131 / 273;
background: var(--card-bg); background: var(--card-bg);
border: 1px solid rgba(0, 0, 0, 0); border: 1px solid rgba(0, 0, 0, 0);
@ -92,17 +92,17 @@
} }
} }
.name { .CharacterName {
@include breakpoint(phone) { @include breakpoint(phone) {
font-size: $font-tiny; font-size: $font-tiny;
} }
} }
&:hover .perpetuity.empty { &:hover .Perpetuity.Empty {
opacity: 1; opacity: 1;
} }
.perpetuity { .Perpetuity {
position: absolute; position: absolute;
background-image: url('/icons/perpetuity/filled.svg'); background-image: url('/icons/perpetuity/filled.svg');
background-size: $unit-4x $unit-4x; background-size: $unit-4x $unit-4x;
@ -118,7 +118,7 @@
cursor: pointer; cursor: pointer;
} }
&.empty { &.Empty {
background-image: url('/icons/perpetuity/empty.svg'); background-image: url('/icons/perpetuity/empty.svg');
opacity: 0; opacity: 0;

View file

@ -1,26 +1,22 @@
'use client' import React, { MouseEvent, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import React, { useEffect, useState } from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { getCookie } from 'cookies-next'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { useTranslations } from 'next-intl' import { Trans, useTranslation } from 'next-i18next'
import { AxiosResponse } from 'axios' import { AxiosResponse } from 'axios'
import classNames from 'classnames' import classNames from 'classnames'
import cloneDeep from 'lodash.clonedeep'
import Alert from '~components/common/Alert' import Alert from '~components/Alert'
import Button from '~components/common/Button' import Button from '~components/Button'
import CharacterHovercard from '~components/character/CharacterHovercard' import CharacterHovercard from '~components/CharacterHovercard'
import CharacterModal from '~components/character/CharacterModal' import CharacterModal from '~components/CharacterModal'
import { import {
ContextMenu, ContextMenu,
ContextMenuTrigger, ContextMenuTrigger,
ContextMenuContent, ContextMenuContent,
} from '~components/common/ContextMenu' } from '~components/ContextMenu'
import ContextMenuItem from '~components/common/ContextMenuItem' import ContextMenuItem from '~components/ContextMenuItem'
import SearchModal from '~components/search/SearchModal' import SearchModal from '~components/SearchModal'
import UncapIndicator from '~components/uncap/UncapIndicator' import UncapIndicator from '~components/UncapIndicator'
import api from '~utils/api' import api from '~utils/api'
import { appState } from '~utils/appState' import { appState } from '~utils/appState'
@ -30,13 +26,12 @@ import SettingsIcon from '~public/icons/Settings.svg'
// Types // Types
import type { import type {
CharacterOverMastery,
GridCharacterObject, GridCharacterObject,
PerpetuityObject, PerpetuityObject,
SearchableObject, SearchableObject,
} from '~types' } from '~types'
import styles from './index.module.scss' import './index.scss'
interface Props { interface Props {
gridCharacter?: GridCharacter gridCharacter?: GridCharacter
@ -58,13 +53,10 @@ const CharacterUnit = ({
updateTranscendence, updateTranscendence,
}: Props) => { }: Props) => {
// Translations and locale // Translations and locale
const t = useTranslations('common') const { t } = useTranslation('common')
const router = useRouter() const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const routerLocale = getCookie('NEXT_LOCALE')
const locale = const locale =
routerLocale && ['en', 'ja'].includes(routerLocale) ? routerLocale : 'en' router.locale && ['en', 'ja'].includes(router.locale) ? router.locale : 'en'
// State snapshot // State snapshot
const { party, grid } = useSnapshot(appState) const { party, grid } = useSnapshot(appState)
@ -80,10 +72,14 @@ const CharacterUnit = ({
// Classes // Classes
const classes = classNames({ const classes = classNames({
unit: true, CharacterUnit: true,
[styles.unit]: true, editable: editable,
[styles.editable]: editable, filled: gridCharacter !== undefined,
[styles.filled]: gridCharacter !== undefined, })
const buttonClasses = classNames({
Options: true,
Clicked: contextMenuOpen,
}) })
// Other // Other
@ -151,20 +147,7 @@ const CharacterUnit = ({
// Save the server's response to state // Save the server's response to state
function processResult(response: AxiosResponse) { function processResult(response: AxiosResponse) {
const gridCharacter: GridCharacter = response.data const gridCharacter: GridCharacter = response.data
let character = cloneDeep(gridCharacter) appState.grid.characters[gridCharacter.position] = gridCharacter
if (character.over_mastery) {
const overMastery: CharacterOverMastery = [
gridCharacter.over_mastery[0],
gridCharacter.over_mastery[1],
gridCharacter.over_mastery[2],
gridCharacter.over_mastery[3],
]
character.over_mastery = overMastery
}
appState.grid.characters[gridCharacter.position] = character
} }
function processError(error: any) { function processError(error: any) {
@ -209,7 +192,7 @@ const CharacterUnit = ({
suffix = `${suffix}_0${element}` suffix = `${suffix}_0${element}`
} }
imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/character-main/${character.granblue_id}_${suffix}.jpg` imgSrc = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/chara-main/${character.granblue_id}_${suffix}.jpg`
} }
setImageUrl(imgSrc) setImageUrl(imgSrc)
@ -236,10 +219,8 @@ const CharacterUnit = ({
<ContextMenu onOpenChange={handleContextMenuOpenChange}> <ContextMenu onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<Button <Button
active={contextMenuOpen}
floating={true}
leftAccessoryIcon={<SettingsIcon />} leftAccessoryIcon={<SettingsIcon />}
className="options" className={buttonClasses}
onClick={handleButtonClicked} onClick={handleButtonClicked}
/> />
</ContextMenuTrigger> </ContextMenuTrigger>
@ -268,12 +249,11 @@ const CharacterUnit = ({
cancelAction={() => setAlertOpen(false)} cancelAction={() => setAlertOpen(false)}
cancelActionText={t('buttons.cancel')} cancelActionText={t('buttons.cancel')}
message={ message={
<> <Trans i18nKey="modals.characters.messages.remove">
{t.rich('modals.characters.messages.remove', { Are you sure you want to remove{' '}
character: gridCharacter?.object.name[locale] || '', <strong>{{ character: gridCharacter?.object.name[locale] }}</strong>{' '}
strong: (chunks) => <strong>{chunks}</strong> from your team?
})} </Trans>
</>
} }
/> />
) )
@ -298,8 +278,8 @@ const CharacterUnit = ({
const perpetuity = () => { const perpetuity = () => {
if (gridCharacter) { if (gridCharacter) {
const classes = classNames({ const classes = classNames({
[styles.perpetuity]: true, Perpetuity: true,
[styles.empty]: !gridCharacter.perpetuity, Empty: !gridCharacter.perpetuity,
}) })
return <i className={classes} onClick={handlePerpetuityClick} /> return <i className={classes} onClick={handlePerpetuityClick} />
@ -317,13 +297,13 @@ const CharacterUnit = ({
const content = ( const content = (
<div <div
className={styles.image} className="CharacterImage"
tabIndex={gridCharacter ? gridCharacter.position * 7 : 0} tabIndex={gridCharacter ? gridCharacter.position * 7 : 0}
onClick={openSearchModal} onClick={openSearchModal}
> >
{image} {image}
{editable ? ( {editable ? (
<span className={styles.icon}> <span className="icon">
<PlusIcon /> <PlusIcon />
</span> </span>
) : ( ) : (
@ -366,7 +346,7 @@ const CharacterUnit = ({
) : ( ) : (
'' ''
)} )}
<h3 className={styles.name}>{character?.name[locale]}</h3> <h3 className="CharacterName">{character?.name[locale]}</h3>
</div> </div>
{searchModal()} {searchModal()}
</> </>

View 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;
}
}

View 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

View file

@ -1,4 +1,4 @@
.menu { .ContextMenu {
background: var(--menu-bg); background: var(--menu-bg);
border-radius: $input-corner; border-radius: $input-corner;
padding: $unit 0; padding: $unit 0;

Some files were not shown because too many files have changed in this diff Show more